From 86a2ccdf841a4d9d3d062b594e96f3eb2f7006ca Mon Sep 17 00:00:00 2001 From: remm Date: Tue, 15 Sep 2026 16:03:12 +0200 Subject: [PATCH 01/17] Add manager2 webapp as a module - Weird endpoint changes to allow flexibility when editing TLS configuration. TODO: - Missing List upgradeProtocols configuration (only specialized HTTP/2 support is really needed in practice). - Add a "Restart" button (which would do a Stop followed by a Start for all components that implement Lifecycle. Not all attribute changes take effect without at least a restart (which attributes work "live" is rather undocumented), so this is needed after certain changes. --- .../apache/catalina/connector/Connector.java | 7 + .../tomcat/util/net/AbstractEndpoint.java | 15 +- .../apache/tomcat/util/net/NioEndpoint.java | 15 + modules/manager2/LICENSE | 1155 ++++ modules/manager2/NOTICE | 5 + modules/manager2/README.md | 123 + modules/manager2/build.properties.default | 63 + modules/manager2/build.xml | 222 + modules/manager2/manager2-design.md | 1082 ++++ modules/manager2/resources/MANIFEST.MF | 6 + .../tomcat/manager2/AccessLogSupport.java | 221 + .../java/org/apache/tomcat/manager2/Api.java | 113 + .../tomcat/manager2/AppsApiServlet.java | 772 +++ .../tomcat/manager2/ConfigApiServlet.java | 5109 +++++++++++++++++ .../org/apache/tomcat/manager2/Constants.java | 53 + .../apache/tomcat/manager2/CsrfFilter.java | 148 + .../apache/tomcat/manager2/ErrorServlet.java | 60 + .../apache/tomcat/manager2/HeadersFilter.java | 67 + .../apache/tomcat/manager2/HomeServlet.java | 76 + .../tomcat/manager2/HostsApiServlet.java | 254 + .../java/org/apache/tomcat/manager2/Html.java | 84 + .../java/org/apache/tomcat/manager2/Json.java | 124 + .../tomcat/manager2/LocalStrings.properties | 108 + .../org/apache/tomcat/manager2/LogParser.java | 292 + .../apache/tomcat/manager2/LoginServlet.java | 247 + .../apache/tomcat/manager2/LogoutServlet.java | 51 + .../tomcat/manager2/LogsApiServlet.java | 756 +++ .../tomcat/manager2/StatusApiServlet.java | 372 ++ .../apache/tomcat/manager2/StatusHistory.java | 237 + .../tomcat/manager2/StatusSnapshot.java | 354 ++ .../org/apache/tomcat/manager2/Strings.java | 62 + .../tomcat/manager2/UsersApiServlet.java | 918 +++ .../tomcat/manager2/TestManager2Config.java | 2176 +++++++ .../tomcat/manager2/TestManager2Webapp.java | 1312 +++++ modules/manager2/webapp/META-INF/context.xml | 32 + modules/manager2/webapp/WEB-INF/web.xml | 383 ++ modules/manager2/webapp/css/manager2.css | 822 +++ modules/manager2/webapp/error-403.html | 35 + modules/manager2/webapp/error-404.html | 34 + modules/manager2/webapp/images/favicon.ico | Bin 0 -> 21630 bytes modules/manager2/webapp/images/tomcat.svg | 967 ++++ modules/manager2/webapp/index.html | 63 + modules/manager2/webapp/js/api.js | 184 + modules/manager2/webapp/js/charts.js | 260 + modules/manager2/webapp/js/logviewer.js | 379 ++ modules/manager2/webapp/js/main.js | 206 + modules/manager2/webapp/js/pages/accesslog.js | 28 + modules/manager2/webapp/js/pages/apps.js | 687 +++ .../manager2/webapp/js/pages/configuration.js | 967 ++++ modules/manager2/webapp/js/pages/dashboard.js | 223 + .../manager2/webapp/js/pages/diagnostics.js | 261 + modules/manager2/webapp/js/pages/hosts.js | 207 + modules/manager2/webapp/js/pages/logs.js | 28 + .../manager2/webapp/js/pages/monitoring.js | 143 + modules/manager2/webapp/js/pages/users.js | 516 ++ modules/manager2/webapp/js/router.js | 101 + modules/manager2/webapp/js/ui.js | 356 ++ modules/manager2/webapp/login.html | 55 + ssl.patch | 34 + 59 files changed, 23627 insertions(+), 3 deletions(-) create mode 100644 modules/manager2/LICENSE create mode 100644 modules/manager2/NOTICE create mode 100644 modules/manager2/README.md create mode 100644 modules/manager2/build.properties.default create mode 100644 modules/manager2/build.xml create mode 100644 modules/manager2/manager2-design.md create mode 100644 modules/manager2/resources/MANIFEST.MF create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/Constants.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/ErrorServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/HeadersFilter.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/Json.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LogoutServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusHistory.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java create mode 100644 modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java create mode 100644 modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java create mode 100644 modules/manager2/webapp/META-INF/context.xml create mode 100644 modules/manager2/webapp/WEB-INF/web.xml create mode 100644 modules/manager2/webapp/css/manager2.css create mode 100644 modules/manager2/webapp/error-403.html create mode 100644 modules/manager2/webapp/error-404.html create mode 100644 modules/manager2/webapp/images/favicon.ico create mode 100644 modules/manager2/webapp/images/tomcat.svg create mode 100644 modules/manager2/webapp/index.html create mode 100644 modules/manager2/webapp/js/api.js create mode 100644 modules/manager2/webapp/js/charts.js create mode 100644 modules/manager2/webapp/js/logviewer.js create mode 100644 modules/manager2/webapp/js/main.js create mode 100644 modules/manager2/webapp/js/pages/accesslog.js create mode 100644 modules/manager2/webapp/js/pages/apps.js create mode 100644 modules/manager2/webapp/js/pages/configuration.js create mode 100644 modules/manager2/webapp/js/pages/dashboard.js create mode 100644 modules/manager2/webapp/js/pages/diagnostics.js create mode 100644 modules/manager2/webapp/js/pages/hosts.js create mode 100644 modules/manager2/webapp/js/pages/logs.js create mode 100644 modules/manager2/webapp/js/pages/monitoring.js create mode 100644 modules/manager2/webapp/js/pages/users.js create mode 100644 modules/manager2/webapp/js/router.js create mode 100644 modules/manager2/webapp/js/ui.js create mode 100644 modules/manager2/webapp/login.html create mode 100644 ssl.patch diff --git a/java/org/apache/catalina/connector/Connector.java b/java/org/apache/catalina/connector/Connector.java index 0013db3d210c..f31e194070fa 100644 --- a/java/org/apache/catalina/connector/Connector.java +++ b/java/org/apache/catalina/connector/Connector.java @@ -1031,6 +1031,13 @@ public String getExecutorName() { public void addSslHostConfig(SSLHostConfig sslHostConfig) { if (protocolHandler != null) { protocolHandler.addSslHostConfig(sslHostConfig); + // A connector with at least one SSL host configuration is an + // SSL connector, whatever the path used to configure it + // (server.xml, the Manager or the embedded API). + if (protocolHandler instanceof AbstractHttp11Protocol http11 + && !http11.isSSLEnabled()) { + http11.setSSLEnabled(true); + } } } diff --git a/java/org/apache/tomcat/util/net/AbstractEndpoint.java b/java/org/apache/tomcat/util/net/AbstractEndpoint.java index b0fe7ff28e5f..49c8bd39cf6b 100644 --- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java +++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java @@ -499,7 +499,12 @@ public SSLHostConfig removeSslHostConfig(String hostName) { // internally because they are used as keys in a ConcurrentMap where // keys are compared in a case-sensitive manner. String hostNameLower = hostName.toLowerCase(Locale.ENGLISH); - if (hostNameLower.equals(getDefaultSSLHostConfigName())) { + // The default host configuration is the fallback for handshakes + // without a matching SNI name, so it cannot be removed while the + // endpoint is still serving TLS. Once TLS is switched off (for + // example to remove the last remaining host configuration) the + // guard no longer applies. + if (isSSLEnabled() && hostNameLower.equals(getDefaultSSLHostConfigName())) { throw new IllegalArgumentException(sm.getString("endpoint.removeDefaultSslHostConfig", hostName)); } SSLHostConfig sslHostConfig = sslHostConfigs.remove(hostNameLower); @@ -823,11 +828,15 @@ private SSLHostConfigCertificate selectCertificate(SSLHostConfig sslHostConfig, /** - * Initialise the SSL configuration. + * Initialize the SSL implementation and (re-)create the SSL context + * of every SSL host configuration. Called from {@code bind()} but + * also made available to components that switch an already bound, + * running endpoint to TLS after the initial bind (which is when the + * SSL implementation and contexts are validated and created). * * @throws Exception If an error occurs while initializing SSL */ - protected void initialiseSsl() throws Exception { + public void initialiseSsl() throws Exception { if (isSSLEnabled()) { sslImplementation = SSLImplementation.getInstance(getSslImplementationName()); diff --git a/java/org/apache/tomcat/util/net/NioEndpoint.java b/java/org/apache/tomcat/util/net/NioEndpoint.java index 9200f44f9457..147cb00dc575 100644 --- a/java/org/apache/tomcat/util/net/NioEndpoint.java +++ b/java/org/apache/tomcat/util/net/NioEndpoint.java @@ -682,6 +682,21 @@ protected SynchronizedStack getNioChannels() { } + @Override + public void setSSLEnabled(boolean SSLEnabled) { + if (SSLEnabled != isSSLEnabled() && nioChannels != null) { + // The channel cache may contain channels of the previous + // type (secure or plain) which must not be re-used once the + // SSL state of the endpoint has changed. + NioChannel channel; + while ((channel = nioChannels.pop()) != null) { + channel.free(); + } + } + super.setSSLEnabled(SSLEnabled); + } + + /** * Returns the poller instance. * diff --git a/modules/manager2/LICENSE b/modules/manager2/LICENSE new file mode 100644 index 000000000000..09da203be558 --- /dev/null +++ b/modules/manager2/LICENSE @@ -0,0 +1,1155 @@ + + 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. + + + +APACHE TOMCAT SUBCOMPONENTS: + +Apache Tomcat includes a number of subcomponents with separate copyright notices +and license terms. Your use of these subcomponents is subject to the terms and +conditions of the following licenses. + + +For the Eclipse JDT Core Batch Compiler (ecj-x.x.x.jar) component and the +following Jakarta EE Schemas: +- jakartaee_9.xsd +- jakartaee_10.xsd +- jakartaee_11.xsd +- jakartaee_12.xsd +- jakarta_web-services_2_0.xsd +- jakarta_web-services_client_2_0.xsd +- jsp_3_0.xsd +- jsp_3_1.xsd +- jsp_4_0.xsd +- jsp_4_2.xsd +- web-app_5_0.xsd +- web-app_6_0.xsd +- web-app_6_1.xsd +- web-app_6_2.xsd +- web-commonn_5_0.xsd +- web-commonn_6_0.xsd +- web-commonn_6_1.xsd +- web-commonn_6_2.xsd +- web-fragment_5_0.xsd +- web-fragment_6_0.xsd +- web-fragment_6_1.xsd +- web-fragment_6_2.xsd +- web-jsptaglibrary_3_0.xsd +- web-jsptaglibrary_3_1.xsd +- web-jsptaglibrary_4_0.xsd +- web-jsptaglibrary_4_1.xsd + +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED 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. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + 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. + + +For the Windows Installer component: + + * All NSIS source code, plug-ins, documentation, examples, header files and + graphics, with the exception of the compression modules and where + otherwise noted, are licensed under the zlib/libpng license. + * The zlib compression module for NSIS is licensed under the zlib/libpng + license. + * The bzip2 compression module for NSIS is licensed under the bzip2 license. + * The lzma compression module for NSIS is licensed under the Common Public + License version 1.0. + +zlib/libpng license + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a + product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + +bzip2 license + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a + product, an acknowledgment in the product documentation would be + appreciated but is not required. + 3. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 4. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. + +Julian Seward, Cambridge, UK. + +jseward@acm.org +Common Public License version 1.0 + +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS COMMON PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and documentation +distributed under this Agreement, and b) in the case of each subsequent +Contributor: + +i) changes to the Program, and + +ii) additions to the Program; + +where such changes and/or additions to the Program originate from and are +distributed by that particular Contributor. A Contribution 'originates' from a +Contributor if it was added to the Program by such Contributor itself or anyone +acting on such Contributor's behalf. Contributions do not include additions to +the Program which: (i) are separate modules of software distributed in +conjunction with the Program under their own license agreement, and (ii) are not +derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents " mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + +a) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free copyright license to +reproduce, prepare derivative works of, publicly display, publicly perform, +distribute and sublicense the Contribution of such Contributor, if any, and such +derivative works, in source code and object code form. + +b) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed +Patents to make, use, sell, offer to sell, import and otherwise transfer the +Contribution of such Contributor, if any, in source code and object code form. +This patent license shall apply to the combination of the Contribution and the +Program if, at the time the Contribution is added by the Contributor, such +addition of the Contribution causes such combination to be covered by the +Licensed Patents. The patent license shall not apply to any other combinations +which include the Contribution. No hardware per se is licensed hereunder. + +c) Recipient understands that although each Contributor grants the licenses to +its Contributions set forth herein, no assurances are provided by any +Contributor that the Program does not infringe the patent or other intellectual +property rights of any other entity. Each Contributor disclaims any liability to +Recipient for claims brought by any other entity based on infringement of +intellectual property rights or otherwise. As a condition to exercising the +rights and licenses granted hereunder, each Recipient hereby assumes sole +responsibility to secure any other intellectual property rights needed, if any. +For example, if a third party patent license is required to allow Recipient to +distribute the Program, it is Recipient's responsibility to acquire that license +before distributing the Program. + +d) Each Contributor represents that to its knowledge it has sufficient copyright +rights in its Contribution, if any, to grant the copyright license set forth in +this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under its +own license agreement, provided that: + +a) it complies with the terms and conditions of this Agreement; and + +b) its license agreement: + +i) effectively disclaims on behalf of all Contributors all warranties and +conditions, express and implied, including warranties or conditions of title and +non-infringement, and implied warranties or conditions of merchantability and +fitness for a particular purpose; + +ii) effectively excludes on behalf of all Contributors all liability for +damages, including direct, indirect, special, incidental and consequential +damages, such as lost profits; + +iii) states that any provisions which differ from this Agreement are offered by +that Contributor alone and not by any other party; and + +iv) states that source code for the Program is available from such Contributor, +and informs licensees how to obtain it in a reasonable manner on or through a +medium customarily used for software exchange. + +When the Program is made available in source code form: + +a) it must be made available under this Agreement; and + +b) a copy of this Agreement must be included with each copy of the Program. + +Contributors may not remove or alter any copyright notices contained within the +Program. + +Each Contributor must identify itself as the originator of its Contribution, if +any, in a manner that reasonably allows subsequent Recipients to identify the +originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a manner +which does not create potential liability for other Contributors. Therefore, if +a Contributor includes the Program in a commercial product offering, such +Contributor ("Commercial Contributor") hereby agrees to defend and indemnify +every other Contributor ("Indemnified Contributor") against any losses, damages +and costs (collectively "Losses") arising from claims, lawsuits and other legal +actions brought by a third party against the Indemnified Contributor to the +extent caused by the acts or omissions of such Commercial Contributor in +connection with its distribution of the Program in a commercial product +offering. The obligations in this section do not apply to any claims or Losses +relating to any actual or alleged intellectual property infringement. In order +to qualify, an Indemnified Contributor must: a) promptly notify the Commercial +Contributor in writing of such claim, and b) allow the Commercial Contributor to +control, and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may participate in +any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If that +Commercial Contributor then makes performance claims, or offers warranties +related to Product X, those performance claims and warranties are such +Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a court +requires any other Contributor to pay any damages as a result, the Commercial +Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED 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. Each +Recipient is solely responsible for determining the appropriateness of using and +distributing the Program and assumes all risks associated with its exercise of +rights under this Agreement, including but not limited to the risks and costs of +program errors, compliance with applicable laws, damage to or loss of data, +programs or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS +GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under applicable +law, it shall not affect the validity or enforceability of the remainder of the +terms of this Agreement, and without further action by the parties hereto, such +provision shall be reformed to the minimum extent necessary to make such +provision valid and enforceable. + +If Recipient institutes patent litigation against a Contributor with respect to +a patent applicable to software (including a cross-claim or counterclaim in a +lawsuit), then any patent licenses granted by that Contributor to such Recipient +under this Agreement shall terminate as of the date such litigation is filed. In +addition, if Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the Program +itself (excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted under +Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and does +not cure such failure in a reasonable period of time after becoming aware of +such noncompliance. If all Recipient's rights under this Agreement terminate, +Recipient agrees to cease use and distribution of the Program as soon as +reasonably practicable. However, Recipient's obligations under this Agreement +and any licenses granted by Recipient relating to the Program shall continue and +survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to time. +No one other than the Agreement Steward has the right to modify this Agreement. +IBM is the initial Agreement Steward. IBM may assign the responsibility to serve +as the Agreement Steward to a suitable separate entity. Each new version of the +Agreement will be given a distinguishing version number. The Program (including +Contributions) may always be distributed subject to the version of the Agreement +under which it was received. In addition, after a new version of the Agreement +is published, Contributor may elect to distribute the Program (including its +Contributions) under the new version. Except as expressly stated in Sections +2(a) and 2(b) above, Recipient receives no rights or licenses to the +intellectual property of any Contributor under this Agreement, whether +expressly, by implication, estoppel or otherwise. All rights in the Program not +expressly granted under this Agreement are reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial in +any resulting litigation. + +Special exception for LZMA compression module + +Igor Pavlov and Amir Szekely, the authors of the LZMA compression module for +NSIS, expressly permit you to statically or dynamically link your code (or bind +by name) to the files from the LZMA compression module for NSIS without +subjecting your linked code to the terms of the Common Public license version +1.0. Any modifications or additions to files from the LZMA compression module +for NSIS, however, are subject to the terms of the Common Public License version +1.0. + + +For the following XML Schemas for Java EE Deployment Descriptors: + - javaee_5.xsd + - javaee_web_services_1_2.xsd + - javaee_web_services_client_1_2.xsd + - javaee_6.xsd + - javaee_web_services_1_3.xsd + - javaee_web_services_client_1_3.xsd + - jsp_2_2.xsd + - web-app_3_0.xsd + - web-common_3_0.xsd + - web-fragment_3_0.xsd + - javaee_7.xsd + - javaee_web_services_1_4.xsd + - javaee_web_services_client_1_4.xsd + - jsp_2_3.xsd + - web-app_3_1.xsd + - web-common_3_1.xsd + - web-fragment_3_1.xsd + - javaee_8.xsd + - web-app_4_0.xsd + - web-common_4_0.xsd + - web-fragment_4_0.xsd + +COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 + +1. Definitions. + + 1.1. Contributor. means each individual or entity that creates or contributes + to the creation of Modifications. + + 1.2. Contributor Version. means the combination of the Original Software, + prior Modifications used by a Contributor (if any), and the + Modifications made by that particular Contributor. + + 1.3. Covered Software. means (a) the Original Software, or (b) Modifications, + or (c) the combination of files containing Original Software with files + containing Modifications, in each case including portions thereof. + + 1.4. Executable. means the Covered Software in any form other than Source + Code. + + 1.5. Initial Developer. means the individual or entity that first makes + Original Software available under this License. + + 1.6. Larger Work. means a work which combines Covered Software or portions + thereof with code not governed by the terms of this License. + + 1.7. License. means this document. + + 1.8. Licensable. means having the right to grant, to the maximum extent + possible, whether at the time of the initial grant or subsequently + acquired, any and all of the rights conveyed herein. + + 1.9. Modifications. means the Source Code and Executable form of any of the + following: + + A. Any file that results from an addition to, deletion from or + modification of the contents of a file containing Original Software + or previous Modifications; + + B. Any new file that contains any part of the Original Software or + previous Modification; or + + C. Any new file that is contributed or otherwise made available under + the terms of this License. + + 1.10. Original Software. means the Source Code and Executable form of + computer software code that is originally released under this License. + + 1.11. Patent Claims. means any patent claim(s), now owned or hereafter + acquired, including without limitation, method, process, and apparatus + claims, in any patent Licensable by grantor. + + 1.12. Source Code. means (a) the common form of computer software code in + which modifications are made and (b) associated documentation included + in or with such code. + + 1.13. You. (or .Your.) means an individual or a legal entity exercising + rights under, and complying with all of the terms of, this License. For + legal entities, .You. includes any entity which 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. + + 2.1. The Initial Developer Grant. + + Conditioned upon Your compliance with Section 3.1 below and subject to + third party intellectual property claims, the Initial Developer hereby + grants You a world-wide, royalty-free, non-exclusive license: + + (a) under intellectual property rights (other than patent or trademark) + Licensable by Initial Developer, to use, reproduce, modify, display, + perform, sublicense and distribute the Original Software (or + portions thereof), with or without Modifications, and/or as part of + a Larger Work; and + + (b) under Patent Claims infringed by the making, using or selling of + Original Software, to make, have made, use, practice, sell, and + offer for sale, and/or otherwise dispose of the Original Software + (or portions thereof). + + (c) The licenses granted in Sections 2.1(a) and (b) are effective on the + date Initial Developer first distributes or otherwise makes the + Original Software available to a third party under the terms of this + License. + + (d) Notwithstanding Section 2.1(b) above, no patent license is granted: + (1) for code that You delete from the Original Software, or (2) for + infringements caused by: (i) the modification of the Original + Software, or (ii) the combination of the Original Software with + other software or devices. + + 2.2. Contributor Grant. + + Conditioned upon Your compliance with Section 3.1 below and subject to third + party intellectual property claims, 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 Contributor to use, reproduce, modify, display, + perform, sublicense and distribute the Modifications created by such + Contributor (or portions thereof), either on an unmodified basis, + with other Modifications, as Covered Software and/or as part of a + Larger Work; and + + (b) under Patent Claims infringed by the making, using, or selling of + Modifications made by that Contributor either alone and/or in + combination with its Contributor Version (or portions of such + combination), to make, use, sell, offer for sale, have made, and/or + otherwise dispose of: (1) Modifications made by that Contributor (or + portions thereof); and (2) the combination of Modifications made by + that Contributor with its Contributor Version (or portions of such + combination). + + (c) The licenses granted in Sections 2.2(a) and 2.2(b) are effective on + the date Contributor first distributes or otherwise makes the + Modifications available to a third party. + + (d) Notwithstanding Section 2.2(b) above, no patent license is granted: + (1) for any code that Contributor has deleted from the Contributor + Version; (2) for infringements caused by: (i) third party + modifications of Contributor Version, or (ii) the combination of + Modifications made by that Contributor with other software (except + as part of the Contributor Version) or other devices; or (3) under + Patent Claims infringed by Covered Software in the absence of + Modifications made by that Contributor. + +3. Distribution Obligations. + + 3.1. Availability of Source Code. + Any Covered Software that You distribute or otherwise make available in + Executable form must also be made available in Source Code form and that + Source Code form must be distributed only under the terms of this License. + You must include a copy of this License with every copy of the Source Code + form of the Covered Software You distribute or otherwise make available. + You must inform recipients of any such Covered Software in Executable form + as to how they can obtain such Covered Software in Source Code form in a + reasonable manner on or through a medium customarily used for software + exchange. + + 3.2. Modifications. + The Modifications that You create or to which You contribute are governed + by the terms of this License. You represent that You believe Your + Modifications are Your original creation(s) and/or You have sufficient + rights to grant the rights conveyed by this License. + + 3.3. Required Notices. + You must include a notice in each of Your Modifications that identifies + You as the Contributor of the Modification. You may not remove or alter + any copyright, patent or trademark notices contained within the Covered + Software, or any notices of licensing or any descriptive text giving + attribution to any Contributor or the Initial Developer. + + 3.4. Application of Additional Terms. + You may not offer or impose any terms on any Covered Software in Source + Code form that alters or restricts the applicable version of this License + or the recipients. rights hereunder. 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 the Initial Developer or 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 the Initial Developer and every Contributor + for any liability incurred by the Initial Developer or such Contributor as + a result of warranty, support, indemnity or liability terms You offer. + + 3.5. Distribution of Executable Versions. + You may distribute the Executable form of the Covered Software under the + terms of this License or under the terms of a license of Your choice, + which may contain terms different from this License, provided that You are + in compliance with the terms of this License and that the license for the + Executable form does not attempt to limit or alter the recipient.s rights + in the Source Code form from the rights set forth in this License. If You + distribute the Covered Software in Executable form under a different + license, You must make it absolutely clear that any terms which differ + from this License are offered by You alone, not by the Initial Developer + or Contributor. You hereby agree to indemnify the Initial Developer and + every Contributor for any liability incurred by the Initial Developer or + such Contributor as a result of any such terms You offer. + + 3.6. Larger Works. + You may create a Larger Work by combining Covered Software with other code + not governed by the terms of this License and distribute the Larger Work + as a single product. In such a case, You must make sure the requirements + of this License are fulfilled for the Covered Software. + +4. Versions of the License. + + 4.1. New Versions. + Sun Microsystems, Inc. is the initial license steward and may publish + revised and/or new versions of this License from time to time. Each + version will be given a distinguishing version number. Except as provided + in Section 4.3, no one other than the license steward has the right to + modify this License. + + 4.2. Effect of New Versions. + You may always continue to use, distribute or otherwise make the Covered + Software available under the terms of the version of the License under + which You originally received the Covered Software. If the Initial + Developer includes a notice in the Original Software prohibiting it from + being distributed or otherwise made available under any subsequent version + of the License, You must distribute and make the Covered Software + available under the terms of the version of the License under which You + originally received the Covered Software. Otherwise, You may also choose + to use, distribute or otherwise make the Covered Software available under + the terms of any subsequent version of the License published by the + license steward. + + 4.3. Modified Versions. + When You are an Initial Developer and You want to create a new license for + Your Original Software, You may create and use a modified version of this + License if You: (a) rename the license and remove any references to the + name of the license steward (except to note that the license differs from + this License); and (b) otherwise make it clear that the license contains + terms which differ from this License. + +5. DISCLAIMER OF WARRANTY. + + COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN .AS IS. BASIS, WITHOUT + WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, 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 THE INITIAL + DEVELOPER OR ANY OTHER 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 + HEREUNDER EXCEPT UNDER THIS DISCLAIMER. + +6. TERMINATION. + + 6.1. This License and the rights granted hereunder will terminate + automatically if You fail to comply with terms herein and fail to + cure such breach within 30 days of becoming aware of the breach. + Provisions which, by their nature, must remain in effect beyond the + termination of this License shall survive. + + 6.2. If You assert a patent infringement claim (excluding declaratory + judgment actions) against Initial Developer or a Contributor (the + Initial Developer or Contributor against whom You assert such claim + is referred to as .Participant.) alleging that the Participant + Software (meaning the Contributor Version where the Participant is a + Contributor or the Original Software where the Participant is the + Initial Developer) directly or indirectly infringes any patent, then + any and all rights granted directly or indirectly to You by such + Participant, the Initial Developer (if the Initial Developer is not + the Participant) and all Contributors under Sections 2.1 and/or 2.2 + of this License shall, upon 60 days notice from Participant terminate + prospectively and automatically at the expiration of such 60 day + notice period, unless if within such 60 day period You withdraw Your + claim with respect to the Participant Software against such + Participant either unilaterally or pursuant to a written agreement + with Participant. + + 6.3. In the event of termination under Sections 6.1 or 6.2 above, all end + user licenses that have been validly granted by You or any + distributor hereunder prior to termination (excluding licenses + granted to You by any distributor) shall survive termination. + +7. LIMITATION OF LIABILITY. + + UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT (INCLUDING + NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL DEVELOPER, ANY + OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED SOFTWARE, OR ANY SUPPLIER OF + ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR ANY 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. U.S. GOVERNMENT END USERS. + + The Covered Software is a .commercial item,. as that term is defined in 48 + C.F.R. 2.101 (Oct. 1995), consisting of .commercial computer software. (as + that term is defined at 48 C.F.R. ? 252.227-7014(a)(1)) and commercial + computer software documentation. as such terms are used in 48 C.F.R. 12.212 + (Sept. 1995). Consistent with 48 C.F.R. 12.212 and 48 C.F.R. 227.7202-1 + through 227.7202-4 (June 1995), all U.S. Government End Users acquire Covered + Software with only those rights set forth herein. This U.S. Government Rights + clause is in lieu of, and supersedes, any other FAR, DFAR, or other clause or + provision that addresses Government rights in computer software under this + License. + +9. MISCELLANEOUS. + + This License represents the complete agreement concerning 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. This License shall be governed by the law of the jurisdiction + specified in a notice contained within the Original Software (except to the + extent applicable law, if any, provides otherwise), excluding such + jurisdiction's conflict-of-law provisions. Any litigation relating to this + License shall be subject to the jurisdiction of the courts located in the + jurisdiction and venue specified in a notice contained within the Original + Software, with the losing party responsible for costs, including, without + limitation, court costs and reasonable attorneys. fees and expenses. The + application of the United Nations Convention on Contracts for the + International Sale of Goods is expressly excluded. Any law or regulation + which provides that the language of a contract shall be construed against + the drafter shall not apply to this License. You agree that You alone are + responsible for compliance with the United States export administration + regulations (and the export control laws and regulation of any other + countries) when You use, distribute or otherwise make available any Covered + Software. + +10. RESPONSIBILITY FOR CLAIMS. + + As between Initial Developer and the Contributors, each party is responsible + for claims and damages arising, directly or indirectly, out of its + utilization of rights under this License and You agree to work with Initial + Developer and Contributors to distribute such responsibility on an equitable + basis. Nothing herein is intended or shall be deemed to constitute any + admission of liability. + + NOTICE PURSUANT TO SECTION 9 OF THE COMMON DEVELOPMENT AND DISTRIBUTION + LICENSE (CDDL) + + The code released under the CDDL shall be governed by the laws of the State + of California (excluding conflict-of-law provisions). Any litigation relating + to this License shall be subject to the jurisdiction of the Federal Courts of + the Northern District of California and the state courts of the State of + California, with venue lying in Santa Clara County, California. + diff --git a/modules/manager2/NOTICE b/modules/manager2/NOTICE new file mode 100644 index 000000000000..2f34cb0f105e --- /dev/null +++ b/modules/manager2/NOTICE @@ -0,0 +1,5 @@ +Apache Tomcat Manager2 +Copyright 2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/modules/manager2/README.md b/modules/manager2/README.md new file mode 100644 index 000000000000..498c48523bb9 --- /dev/null +++ b/modules/manager2/README.md @@ -0,0 +1,123 @@ +# Tomcat Manager2 + +`manager2` is an experimental, self-contained replacement for the classic +`/manager` and `/host-manager` web applications, extended with live runtime +monitoring. It is a single web application deployed at `/manager2` that +combines: + +- **Web application management** — list, deploy (server-side path or upload), + start, stop, reload, undeploy, and session management (list, detail, + attributes, invalidate). +- **Virtual host management** — list, add, start, stop, remove and persist + hosts. +- **Runtime monitoring** — JVM, memory and thread-pool gauges, per-connector + worker statistics, per-application detail, plus diagnostics (memory leaks, + global resources, VM info, thread dump, SSL ciphers/certificates). + +The interface is a dependency-free JavaScript SPA (no JSPs, no build step) +backed by a small JSON API. This module is experimental: it is not included +in the default distribution and its API is not yet stable. + +## Layout + +``` +modules/manager2/ + build.xml Standalone Ant build (jar, war, deploy, test) + build.properties.default Version + main-build location properties + resources/MANIFEST.MF Jar manifest (bundle metadata) + src/main/java/org/apache/tomcat/manager2/ + AppsApiServlet.java /api/apps/*, /api/ssl/*, /api/leaks, + /api/resources, /api/diagnostics/* + (extends HTMLManagerServlet) + HostsApiServlet.java /api/hosts/* (extends HostManagerServlet) + StatusApiServlet.java /api/info, /api/csrf, /api/status, /api/status/* + StatusHistory.java Rolling history of status samples, collected in + the background and served by /api/status/history + StatusSnapshot.java MBean collection for the status endpoints + CsrfFilter.java Per-session CSRF token (X-CSRF-Token header) + HeadersFilter.java Content-Security-Policy / Referrer-Policy + HomeServlet.java / + SPA deep links: login gate / SPA shell + LoginServlet.java /login: renders the login page, keeps the + post-login redirect at the app root + LogoutServlet.java /logout: invalidates the session + ErrorServlet.java /error: renders the 403/404 pages + Html.java Template rendering (base element injection) + Api.java, Json.java, Constants.java, LocalStrings.properties + src/test/java/org/apache/tomcat/manager2/ + TestManager2Webapp.java Integration tests (TomcatBaseTest) + webapp/ + index.html SPA shell + login.html Login page template (served by LoginServlet) + error-403.html Error page templates (served by ErrorServlet) + error-404.html + css/manager2.css Design system (light/dark, responsive) + js/*.js SPA (vanilla ES modules, no dependencies) + WEB-INF/web.xml Servlets, filters, constraints, login-config + META-INF/context.xml Privileged context + hardened cookie processor +``` + +The servlet package is `org.apache.tomcat.manager2` rather than +`org.apache.catalina.manager2`: classes whose names start with +`org.apache.catalina` are always loaded by the container class loader +(`DefaultInstanceManager`), which would make the web application's own jar +unreachable for them. + +## Requirements + +- A main Tomcat build with `${tomcat.home}/output/build/lib` populated + (run `ant` in the main tree first). +- Ant, and a JDK matching the main build. +- For `ant test`: the main tree's `output/testclasses` (from `ant test` or a + full build) and the JUnit/HAMCREST jars in `${user.home}/tomcat-build-libs`. + +## Building + +```sh +cd modules/manager2 +ant # produces output/manager2.jar and output/manager2.war +ant deploy # additionally copies manager2.war into output/build/webapps +ant test # builds, deploys and runs the integration tests +``` + +`build.properties` (local, not committed) can override the properties from +`build.properties.default`, in particular `tomcat.home`/`tomcat.build` if the +main build lives elsewhere. + +The WAR is self-contained: the servlets ship in `WEB-INF/lib/manager2.jar`. +The context is configured via `META-INF/context.xml` to run privileged +(required by the management servlets) with a hardened `Rfc6265CookieProcessor` +(`SameSite=Strict`) and, for convenience in development, a +`RemoteCIDRValve` allowing loopback only. + +## Using + +Deploy `manager2.war` (or the unpacked directory) and create users with the +usual `manager-gui` role in `conf/tomcat-users.xml`; `manager-status` grants +read-only access to the status endpoints. Log in at `/manager2/` (HTTP FORM +authentication). State-changing API calls require the per-session +`X-CSRF-Token` header, which is returned in the `X-CSRF-Token` response +header of every API response. + +The Dashboard charts are driven by `GET /api/status/history`. The web +application collects a sample in the background (on the server utility +executor, from deployment time because the `StatusApi` servlet is +load-on-startup) and keeps the samples of the configured window, so the +charts always show the last window of server activity regardless of when +the page was opened. The collection period and the window are configured +with the `tickMs` and `windowMs` init parameters of the `StatusApi` servlet +(defaults: `2000` ms and `600000` ms, i.e. 300 samples; `tickMs` must be at +least `500` and `windowMs` at least one tick). + +## Testing + +`ant test` runs `TestManager2Webapp`, a `TomcatBaseTest` that deploys the +built WAR into a throw-away Tomcat instance and drives it over HTTP: + +- FORM login flow (including a bad-password case) +- CSRF token issuance and rejection of mutations without a token +- Role separation (`manager-status` is read-only) +- Application list, stop/start/undeploy lifecycle and a deploy from a + server-side WAR +- Session list/detail/invalidate against a JSP-created session +- Status, status history, workers and per-application detail endpoints +- Hosts endpoint diff --git a/modules/manager2/build.properties.default b/modules/manager2/build.properties.default new file mode 100644 index 000000000000..928a473bb5c4 --- /dev/null +++ b/modules/manager2/build.properties.default @@ -0,0 +1,63 @@ +# ----------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# ----------------------------------------------------------------------------- +# +# Build properties for the Tomcat Manager2 module. +# +# Copy this file to "build.properties" (in this directory) and customize it +# for your local environment. Values in "build.properties" override the +# values here. +# ----------------------------------------------------------------------------- + +# ----- Version ----- +version.major=1 +version.minor=0 +version.build=0 +version.patch=0 +version.suffix=-SNAPSHOT + +# ----- Base path of the Tomcat source tree ----- +# The module is built against a Tomcat build (ant) from the main source +# tree. By default the main tree is assumed to be two levels up. +tomcat.home=${basedir}/../.. + +# Build output of the main Tomcat build (ant) in the main tree. The +# manager2 module needs the built lib/ jars (catalina.jar, servlet-api.jar, +# tomcat-util.jar) to compile and the built distribution to deploy into. +tomcat.build=${tomcat.home}/output/build + +# ----- Base path for dependent packages (main tree) ----- +# Must match the main tree's build.properties "base.path" so that the +# JUnit/Hamcrest jars used by the main build can be found for testing. +base.path=${user.home}/tomcat-build-libs + +junit.version=4.13.2 +junit.home=${base.path}/junit-${junit.version} +junit.jar=${junit.home}/junit-${junit.version}.jar + +hamcrest.version=3.0 +hamcrest.home=${base.path}/hamcrest-${hamcrest.version} +hamcrest.jar=${hamcrest.home}/hamcrest-${hamcrest.version}.jar + +# ----- Compile settings (keep in sync with the main tree) ----- +compile.release=21 +compile.debug=true +compile.deprecation=false +encoding=UTF-8 + +# ----- Test settings ----- +test.entry= +test.reports=${tomcat.output}/reports diff --git a/modules/manager2/build.xml b/modules/manager2/build.xml new file mode 100644 index 000000000000..a582c00a73de --- /dev/null +++ b/modules/manager2/build.xml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md new file mode 100644 index 000000000000..497161e373a1 --- /dev/null +++ b/modules/manager2/manager2-design.md @@ -0,0 +1,1082 @@ +# manager2 — Design of a modern Tomcat manager web application + +Status: proposal +Date: 2026-09-11 + +## 1. Overview + +`manager2` is a new web application, deployed at `/manager2`, that replaces the +human-facing parts of the three existing management web applications: + +| Existing webapp | Servlet | What manager2 inherits | +|---|---|---| +| `webapps/manager` | `HTMLManagerServlet` (`/html/*`) | web application lifecycle, deployment, session management, SSL & leak diagnostics | +| `webapps/host-manager` | `HTMLHostManagerServlet` (`/html/*`) | virtual host lifecycle and configuration persistence | +| `webapps/manager` | `StatusManagerServlet` (`/status/*`) | JVM, connector and per-application runtime statistics | + +Goals: + +1. **One webapp** for webapp management, host management and monitoring, + instead of two webapps with three different dated UIs. +2. **Clean, modern, responsive UI** — a single page application with no build + toolchain, no third-party JS dependencies, that works on desktop, tablet + and phone. +3. **Live-updating runtime statistics** — graphs and tables that refresh + automatically while the page is open. +4. **Fully secured** — HTTP FORM authentication, CSRF protection on every + state-changing operation, hardened security headers, role-based + authorization, audit logging. + +Non-goals (staying with the existing webapps): + +- The scriptable *text* API (`/text/*`, `ManagerServlet`) and the JMX proxy + (`/jmxproxy/*`) are untouched. `manager2`'s JSON API becomes the modern + scriptable interface, but the legacy endpoints keep working for existing + automation. +- No multi-node/cluster management; scope is the same as today: the `Host` + the webapp is installed in (for webapps) and the `Engine` it belongs to + (for hosts). + +## 2. Feature parity + +Everything a user can do today in the three HTML interfaces must be possible +in manager2, mapped as follows: + +**From HTMLManagerServlet** + +| Feature | manager2 page / API | +|---|---| +| List contexts (path, version, display name, state, sessions, docBase) | Applications page / `GET /api/apps` | +| Start / stop / reload / undeploy a context | row actions, confirm modal | +| Deploy from WAR/config on the server (path, version, config, war, replace) | Deploy wizard, "From file on server" tab | +| Deploy by WAR upload (multipart, 50 MiB cap) | Deploy wizard, "Upload" tab with progress bar | +| Session list (11 sort columns), session detail, attributes | Applications detail, Sessions tab | +| Invalidate selected sessions / expire idle sessions | Sessions tab | +| Remove a single session attribute | session detail drawer | +| SSL connector ciphers / certs / trusted certs | Diagnostics page | +| Reload SSL host config (all or one `tlsHostName`) | Diagnostics page | +| Find reloaded-context memory leaks (`findleaks`) | Diagnostics page | +| JNDI global resources (`/resources?type=`) | Diagnostics page | +| VM info, thread dump | Diagnostics page | +| Server info panel (version, JVM, OS, host, IP) | persistent header strip | +| `showProxySessions` behaviour (backup/StoreManager proxy sessions) | servlet init-param, kept | + +**From HTMLHostManagerServlet** + +| Feature | manager2 page / API | +|---|---| +| List virtual hosts (name, aliases) | Hosts page / `GET /api/hosts` | +| Add host (name, aliases, appBase, manager, autoDeploy, deployOnStartup, deployXML, unpackWARs, copyXML) | "Add host" modal | +| Start / stop / remove a host | row actions (own host protected, as today) | +| Persist configuration to `server.xml` (StoreConfigLifecycleListener) | Hosts page button | + +**From StatusManagerServlet** + +| Feature | manager2 page / API | +|---|---| +| JVM memory (free/total/max) + per memory-pool table (type, init, committed, max, used) | Dashboard (live) / `GET /api/status` | +| Per-connector thread pool (max, current, busy, keep-alive) | Dashboard (live) | +| Per-connector aggregates (request count, error count, bytes in/out, avg & max processing time) | Dashboard (live) | +| Per-socket (RequestProcessor) live table (stage, time, bytes, remote addr, vhost, method + URI + query + protocol) | Monitoring page (live) | + | Per-application detail (state, start time, startup time, TLD scan time, session manager stats, JSP monitor stats, per-servlet wrapper stats) | Applications detail, Metrics tab / `GET /api/status/apps/{path}` | + | `?XML=true` / `?JSON=true` machine output | replaced by the versioned JSON API | + +**New (no legacy equivalent)** + +| Feature | manager2 page / API | +|---|---| + | Browse and edit the whole live server configuration (services, engines, hosts, contexts, wrappers, valves, connectors, executors, aliases, lifecycle listeners, realms with sub realms, the context sub components — manager with its session id generator, resources, loader, cookie processor — the cluster and its full channel (membership, sender + transport, receiver, interceptors, deployer, manager template, cluster valves and listeners) on any container, the JNDI naming resources of the server and of each context, and TLS: SSL host configurations with their certificates): property editing, structural add/remove, persistence to `server.xml` | Configuration page / `GET /api/config/*` (the legacy manager required hand-editing `server.xml` and a restart for anything beyond the host manager's "persist" button; TLS, realms and JNDI entries apply live, without a restart) | + +## 3. Architecture + +``` + +----------------------------------------------------------+ + | webapps/manager2 (the WAR from modules/manager2) | + Browser <--> | index.html (SPA shell) /login (LoginServlet) | + (same-origin | css/ js/ (static, no build step, no 3rd-party JS) | + fetch) +------------------------+---------------------------------+ + | session cookie + X-CSRF-Token + +------------------+-------------------------+ + | ServletContainer (filters) | + | CsrfFilter -> HttpHeaderSecurityFilter | + | security-constraints on /api/* (FORM auth) | + +------------------+-------------------------+ + | + +------------------------------+---------------------------------+ + | org.apache.tomcat.manager2 (WEB-INF/lib/manager2.jar) | + | | + | HomeServlet / + SPA deep links: login gate + shell | + | LoginServlet /login + /logout (LogoutServlet) + /error | + | AppsApiServlet extends ManagerServlet | + | /api/apps/* reuses deploy/start/stop/reload/undeploy/ | + | expire/sessions/ssl/leaks logic | + | HostsApiServlet extends HostManagerServlet | + | /api/hosts/* reuses add/remove/start/stop/persist | + | StatusApiServlet standalone | + | /api/* StatusSnapshot: same MBean queries as | + | StatusManagerServlet/StatusTransformer | + | but serializes typed JSON | + | ConfigApiServlet standalone (direct container API) | + | /api/config/* live component tree, attribute updates, | + | structural add/remove, storeconfig | + +---------------------------------------------------------------------+ + | + JMX MBeanServer (ThreadPool, + GlobalRequestProcessor, RequestProcessor, + WebModule, Manager, JspMonitor, Wrapper) +``` + +Key decisions: + +1. **Reuse, do not fork.** The new API servlets extend the existing + `ManagerServlet` / `HostManagerServlet` (the same pattern + `HTMLManagerServlet` already uses) and call the same protected methods. + Container wiring (`ContainerServlet.setWrapper`) is inherited, so all of + the existing safety logic — context name validation, `pathCheck` + canonical-path containment, `tryAddServiced` races guard, "cannot + undeploy the context running this servlet", "cannot stop/remove the host + running this servlet" — is inherited for free. The one exception is + `ConfigApiServlet`: it works on the whole `Server` tree (services, + engines, connectors, executors, valves) which those servlets do not + expose, so it is a standalone `HttpServlet` on the direct container + APIs with its own guards (same "cannot touch the component hosting this + webapp" rules, plus `LAST_SERVICE` / `BASIC_COMPONENT`). +2. **JSON built from typed objects, not parsed text.** Read-only endpoints + build their JSON from the container/MBean API directly (as + `StatusManagerServlet` already does). Mutating endpoints invoke the + inherited protected methods with a `StringWriter` and wrap the result in + a stable JSON envelope; the localized text is carried in `message` for + display. +3. **No JSP.** The webapp contains static HTML/CSS/JS plus servlets. This + removes a whole class of rendering surface and keeps the bundle small. +4. **No new Java dependencies** (JDK + existing Tomcat/Jakarta APIs only) + and **no JS dependencies** (hand-written ES modules), so the Ant build + stays self-contained and the attack surface stays minimal. +5. **i18n** — operation result messages reuse the existing + `org.apache.catalina.manager` StringManager bundles (already translated to + 10 locales). New UI-only strings live in a JS dictionary module + (English first; server messages are shown as-is and already localized). + +## 4. JSON API + +Base path: `/manager2/api`. All responses are `application/json; +charset=UTF-8`, `Cache-Control: no-store`. Mutations return an envelope: + +```json +{ "ok": true, "message": "Deployed web application: /docs" } +``` + +Errors use `{"ok": false, "message": "...", "error": "DEPLOY_FAILED"}` with a +machine-readable `error` code and non-2xx status. + +| Method | Path | Role(s) | Purpose | +|---|---|---|---| +| GET | `/api/info` | any authenticated | server version, JVM, OS, host name/IP, uptime | +| GET | `/api/csrf` | any authenticated | current CSRF token | +| GET | `/api/hosts` | manager-gui | hosts: name, aliases, appBase, state, started (true while the host is accepting applications - the same `getState().isAvailable()` test the classic host manager uses) | +| POST | `/api/hosts` | manager-gui | add host: name, aliases[], appBase, manager, autoDeploy, deployOnStartup, deployXML, unpackWARs, copyXML | +| POST | `/api/hosts/{name}/start` | manager-gui | start host | +| POST | `/api/hosts/{name}/stop` | manager-gui | stop host | +| DELETE | `/api/hosts/{name}` | manager-gui | remove host | +| POST | `/api/hosts/persist` | manager-gui | persist configuration to server.xml | +| GET | `/api/apps?host={name}` | manager-gui | list contexts: path, version, displayName, state, sessions, docBase, self | +| POST | `/api/apps/{path}/start` | manager-gui | start context (`?version=`) | +| POST | `/api/apps/{path}/stop` | manager-gui | stop context | +| POST | `/api/apps/{path}/reload` | manager-gui | reload context | +| DELETE | `/api/apps/{path}` | manager-gui | undeploy context | +| POST | `/api/apps/{path}/expire` | manager-gui | expire idle sessions: `{"idle": 30}` | +| POST | `/api/apps/deploy` | manager-gui | deploy from server: `{path, version, config, war, replace}` | +| POST | `/api/apps/upload` | manager-gui | multipart WAR upload: `war`, `path`, `version`, `replace` | +| GET | `/api/apps/{path}/sessions?sort=&order=&cursor=` | manager-gui | session list, server-side sorted, cursor-paginated | +| GET | `/api/apps/{path}/sessions/{id}` | manager-gui | session detail incl. attributes | +| POST | `/api/apps/{path}/sessions/invalidate` | manager-gui | `{"ids": [...]}` | +| DELETE | `/api/apps/{path}/sessions/{id}/attributes/{name}` | manager-gui | remove one session attribute | +| GET | `/api/status` | manager-gui, manager-status | compact live snapshot (see below) | +| GET | `/api/status/workers` | manager-gui, manager-status | live per-socket (RequestProcessor) table | +| GET | `/api/status/apps/{path}` | manager-gui, manager-status | detailed per-app: state, times, sessions, JSPs, servlets | +| GET | `/api/ssl/ciphers` | manager-gui | SSL ciphers per connector | +| GET | `/api/ssl/certs` | manager-gui | SSL certs per connector | +| GET | `/api/ssl/trusted` | manager-gui | SSL trusted certs per connector | +| POST | `/api/ssl/reload` | manager-gui | `{"tlsHostName": ...}` (omit = all) | +| GET | `/api/leaks` | manager-gui | reloaded-context memory leak candidates | +| GET | `/api/resources?type={fqcn}` | manager-gui | global JNDI resources as `name:class` lines (the human readable status header the classic manager renders first is stripped) | +| GET | `/api/diagnostics/vminfo` | manager-gui | VM info text | +| GET | `/api/diagnostics/threaddump` | manager-gui | thread dump text | +| GET | `/api/logs` | manager-gui | server log files (JULI): name, size, modified, detected format (text/JSON) | +| GET | `/api/logs/file?name=&lines=&level=&search=` | manager-gui | parsed + filtered tail of one server log file: the most recent `lines` (default 500) matching records, ordered most recent first (time, level, thread, source, message, throwable), level counts | +| GET | `/api/access-log` | manager-gui | access log files: name, size, modified, format, configured pattern, available fields | + | GET | `/api/access-log/file?name=&lines=&method=&status=&user=&session=&search=` | manager-gui | parsed + filtered tail of one access log file: records, status-class counts, method counts | + | GET | `/api/users?name=` | manager-gui | configured `UserDatabase` JNDI resources (name, id, type, readonly, writable) plus users (username, fullName, hasPassword, roles, groups, effectiveRoles), groups (groupname, description, roles, members) and roles of the selected database | + | POST | `/api/users` | manager-gui | `{"username", "password", "fullName"?, "roles"?, "name"?}` create a user (409 when it exists); new roles are created on the fly | + | DELETE | `/api/users/{username}?name=` | manager-gui | remove a user (404 when absent; 400 `SELF_REMOVAL` for the signed-in account) | + | POST | `/api/users/{username}/password` | manager-gui | `{"password", "name"?}` change a user's password (stored as provided, same semantics as `tomcat-users.xml`) | + | POST | `/api/users/{username}/roles` | manager-gui | `{"roles", "name"?}` replace the roles of a user | + | POST | `/api/groups` | manager-gui | `{"groupname", "description"?, "roles"?, "name"?}` create a group (409 when it exists) | + | DELETE | `/api/groups/{groupname}?name=` | manager-gui | remove a group and detach it from all users | + | POST | `/api/groups/{groupname}/members` | manager-gui | `{"members", "name"?}` replace the members of a group (400 `UNKNOWN_GROUP_MEMBER` when a listed user does not exist) | + | POST | `/api/groups/{groupname}/roles` | manager-gui | `{"roles", "name"?}` replace the roles of a group | + | POST | `/api/roles` | manager-gui | `{"rolename", "description"?, "name"?}` create a role (409 when it exists) | + | DELETE | `/api/roles/{rolename}?name=` | manager-gui | remove a role and detach it from all users and groups (404 when absent; 400 `SELF_ROLE_REMOVAL` when the signed-in account holds the role, directly or through a group) | + | GET | `/api/config/tree` | manager-gui | the live component tree below `Server`: `{"tree": {id, type, className, name, state?, self?, children[]}}`; `self: true` on the context hosting this webapp; a context carries its `manager` (with the manager's `sessionIdGenerator`), `resources`, `loader` and `cookieProcessor` as children (a running context always has all of them); the `Server` and each context carry a single `namingResources` node (the `NamingResourcesImpl`) whose children are the JNDI entries, keyed by JNDI name — `resource`, `resourceLink` (context only), `resourceEnvRef`, `environment`, `ejb`, `localEjb`, `serviceRef`; an engine, host or context that owns a cluster carries a single `cluster` child (an inherited parent cluster is not a child) whose children are the `channel` (holding `membership`, `sender` — with a `transport` child for a replication transmitter —, `receiver` and the repeatable `interceptor` nodes), the repeatable `clusterValve`, the `clusterManager` (with its `sessionIdGenerator`), the repeatable `clusterListener` and the repeatable `listener` | +| GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description`, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | +| POST | `/api/config/attribute` | manager-gui | `{"id", "name", "value", "confirm"?}` — set one writable property on the live component (the change takes effect immediately); `name`/`path`/`defaultHost` additionally require `confirm` to equal the current value; a TLS attribute of a running, TLS enabled connector is re-validated by reloading the affected host configuration and the previous value is restored (400 `UPDATE_FAILED`) when the new value does not validate. For a JNDI entry, editing an attribute (or a `param` — an empty value removes it) is applied by removing and re-adding the entry (the `NamingContextListener` reacts to the property-change events to rebind the live JNDI environment); the previous state is restored (400 `UPDATE_FAILED`) when the re-add fails (e.g. the new JNDI name is already in use) | + | POST | `/api/config/child` | manager-gui | `{"parent", "type", ...}` — add a `service` (created together with an engine of the same name), `host`, `context` (docBase auto-created), `wrapper`, `valve`, `connector`, `executor`, `alias`, `realm` (any container, or a `CombinedRealm` for a sub realm; instantiated from `className`), a context sub component — `manager`, `resources`, `loader` or `cookieProcessor` (parent: a context) or `sessionIdGenerator` (parent: a manager; all instantiated from `className` and **replacing** the current instance, since a context/manager holds exactly one of each) — or `listener` (any parent whose component implements `Lifecycle`; instantiated from `className` and registered, not started); a `cluster` (parent: an engine, host or context) is instantiated from `className` (default `SimpleTcpCluster`) and attached via `setCluster`, which starts the channel and applies the cluster defaults (the addition is rolled back + 400 `START_FAILED` when it cannot start), and its sub components are added by `className` with a sensible default — `channel` (default `GroupChannel`, replacing the current one), `membership` (default `McastService`, parent: the channel), `sender` (default `ReplicationTransmitter`, parent: the channel), `receiver` (default `NioReceiver`, parent: the channel), `interceptor` (parent: the channel, repeatable), `clusterValve` (a `Valve` that is a `ClusterValve`, parent: the cluster, repeatable; 400 `INVALID_CLASS` otherwise), `deployer` (default `FarmWarDeployer`, parent: the cluster), `clusterManager` (default `DeltaManager`, parent: the cluster), `transport` (default `PooledParallelSender`, parent: the sender) and `clusterListener` (parent: the cluster, repeatable); the single-valued slots replace the current instance; structural components are started immediately and the addition rolled back when the start fails. Replacing a context's `manager` or `loader` on a running context stops the old instance and starts the new one (rolled back + 400 `START_FAILED` when it does not start); replacing the `resources` of a running context is refused (400 `CONTEXT_RUNNING` — stop the context first); replacing the `manager` or `loader` of this webapp's own context is refused (403 `SELF_COMPONENT` — it would destroy the admin session / the running classes). A `sslHostConfig` (parent: a connector) optionally carries an initial `certificate` object; on a running connector the TLS configuration is validated and applied without a restart (400 `ADD_FAILED` + rollback otherwise); the first certificate is required for a running connector (400 `INVALID_VALUE`). A further `certificate` is added with the crypto type in the `type` field (`RSA`, `DSA`, ...). A JNDI entry (`parent`: a `namingResources` node) — `resource`, `resourceLink` (refused at the server level, 400 `BAD_PARENT`), `resourceEnvRef`, `environment`, `ejb`, `localEjb` or `serviceRef` — requires `name` and `jndiType`; a `resourceLink` additionally requires `global`; a `factory` (a `resource` parameter / `resourceLink` attribute) must be loadable (400 `INVALID_CLASS`); a `params` object carries the entry's generic string parameters (the `ResourceBase` property map) for any entry type — validated against the closed option set of a first-party factory for a `resource`, free-form otherwise; a JNDI name already in use is 409 `DUPLICATE`, a missing `jndiType` 400 `MISSING_FIELD`; the entry is registered and bound in the live JNDI environment at once | + | DELETE | `/api/config/child` | manager-gui | `{"id", "confirm"?}` — remove a component (containers are stopped recursively); `confirm` must equal the component's display name for `host`/`context`/`service`/`engine`/`connector`/`executor`/`wrapper`/`valve`/`sslHostConfig`/`realm`/`cluster` and for the JNDI entries (`resource`/`resourceLink`/`resourceEnvRef`/`environment`/`ejb`/`localEjb`/`serviceRef`, whose JNDI name is unbound from the live JNDI environment); 400 `LAST_SERVICE`, 403 `SELF_COMPONENT`, 400 `BASIC_COMPONENT`, 400 `NOT_EMPTY`, 400 `LAST_REALM` (the container would be left without a realm), 400 `REQUIRED_COMPONENT` (the context's `manager`/`resources`/`loader`/`cookieProcessor`, a manager's `sessionIdGenerator` and the `namingResources` node itself are required and cannot be removed), 400 `SSL_DEFAULT` and 400 `SSL_LAST_CERTIFICATE` guards apply; removing the last SSL host configuration of a running, TLS enabled connector switches it back to plain HTTP; a `cluster` (which stops the cluster and its channel) and its single-valued children (`channel`, `membership`, `sender`, `receiver`, `deployer`, `clusterManager`, `transport`, `clusterListener` and the static `member`) can be detached from their parent, but a `clusterValve` or `interceptor` has no removal API on a running cluster and is refused (400 `REMOVE_NOT_SUPPORTED`) | + | GET | `/api/config/store/preview` | manager-gui | `{"xml", "files", "restartsManager"}` — the resulting `server.xml`, the external context files that would be rewritten, and whether the save restarts the manager; read-only, the external files are captured in memory and nothing is written | + | POST | `/api/config/store` | manager-gui | persist the live state (`{ok, file, backup}`): a timestamped backup of the previous `conf/server.xml` is kept and each context is written back to its current location — inline in `server.xml` if defined inline, its own file otherwise (mirroring regular StoreConfig) | + +Node ids are path-based: `server/service/Catalina/engine/Catalina/host/localhost/context/+manager2`. +Each segment is the component's name; a context's path is encoded (`/` → +`+`, the root context is a bare `+`). Clients percent-encode each segment +when building a URL so the container's single path decode yields the +(id) segments the server expects. + + +`GET /api/status` (the poll endpoint, kept small — typically 1–2 KB): + +```json +{ + "ts": 1726012345678, + "jvm": { + "heapUsed": 201326592, "heapCommitted": 402653184, "heapMax": 805306368, + "nonHeapUsed": 117440512, + "pools": [ { "name": "G1 Eden Space", "type": "GENERATION", + "init": 0, "committed": 285212672, "max": -1, "used": 184549376 } ] + }, + "connectors": [ + { "name": "http-nio-8080", + "threads": { "max": 200, "current": 10, "busy": 3, "keepAlive": 2 }, + "requests": { "count": 1520, "errors": 4, + "bytesReceived": 912345, "bytesSent": 8923456, + "processingTime": 1450, "maxTime": 480 } } + ], + "apps": [ { "path": "/docs", "host": "localhost", "state": "RUNNABLE", + "activeSessions": 7 } ] +} +``` + +Rate values (requests/s, bytes/s) are computed client-side from counter +deltas between polls; the server exposes monotonic counters and the +server-side timestamp, so restarts and clock skew are handled. + +## 5. User interface + +### 5.1 Shell + +- Top bar: Tomcat logo (the `tomcat.svg` mascot from the root webapp, also + used as `favicon.ico` and on the login page) + product name, server + identity (version, host, uptime), global search, user menu (logout), + theme toggle. +- Left navigation (collapses to bottom tab bar under 768 px): Dashboard, + Applications, Hosts, Configuration, Users, Monitoring, Diagnostics, + Logs, Access log. +- History-API routing (deep links work), one `index.html`, no full page + reloads. `login.html` is the FORM-login page (see §7); it POSTs to + `j_security_check` and redirects back to the original URL. + +### 5.2 Pages + +**Dashboard** (default view) +- KPI cards: heap used (gauge vs max), busy threads (gauge vs max) per + connector, total active sessions, request rate, error rate. +- Live line charts (2 s cadence, ~5 min rolling window): heap used / + committed, busy threads per connector, request rate, error rate, + bytes in/out per connector. The rate / bytes series are derived from + counter deltas, so the first sample (no baseline yet) is not plotted - + those charts start with the first measurable rate instead of an + inaccurate 0. +- Context state strip: one dot per application (green running / red stopped + / grey), click-through to Applications. + +**Applications** +- Table: host, context path (link), version, display name, state, active + sessions, docBase. Filter box, sortable columns. +- Row actions: start / stop / reload / undeploy (confirm modal, undeploy + asks for typing the context path), sessions. +- Deploy wizard (modal, two tabs): *Upload* (drag-and-drop or file picker, + progress bar, optional version + replace) and *From server* (path, + version, WAR file/dir, context config file, replace). +- Detail view (route `/apps/{host}/{path}`) with tabs: + - *Overview*: state, paths, version, links. + - *Sessions*: sortable table (creation time, id, last accessed, max + inactive interval, new, locale, user, used time, inactive time, TTL), + row click opens a drawer with attributes (remove attribute, invalidate + session), bulk invalidate, "expire idle ≥ N minutes". + - *Metrics*: per-servlet table (request count, processing time, max time, + error count, load time, class-load time), JSP stats, session manager + stats, startup/TLD-scan times. + +**Hosts** +- Table: name, aliases, appBase, state. +- "Add host" modal with all parameters from the legacy add form + (name, aliases, appBase, manager, autoDeploy, deployOnStartup, + deployXML, unpackWARs, copyXML). +- Row actions: start/stop/remove (own host shown as protected, as today). +- "Persist configuration to server.xml" button with confirmation. + +**Configuration** +- Two-pane layout (component tree card + detail card, stacked below + 1100 px). Header: *Reload* and *Save to server.xml*. +- Tree: the live component tree below `Server` (services, engines, + hosts, contexts, wrappers, valves, connectors, executors, aliases, + listeners, realms, the context sub components (manager, resources, + loader, cookie processor; the manager's session id generator under + the manager), and the TLS branch of a connector: SSL host + configurations with their certificates). Each row: a type badge, the + name, a state badge for lifecycle components, and a "this app" badge + on the context hosting this webapp. Nodes expand/collapse; the + `Server` node starts expanded. A container only shows the realm it + owns; an inherited parent realm is not a child (the same + comparison storeconfig uses to decide whether to write a `` + element). A `realm` node shows its sub realms when it is a + `CombinedRealm` (e.g. a `LockOutRealm` wrapping a + `UserDatabaseRealm`). A running context always shows its four sub + components (the defaults — `StandardManager`, `StandardRoot`, + `WebappLoader`, `Rfc6265CookieProcessor` — are created at context + start). The `Server` and each context also show a `namingResources` + node (the JNDI environment, `NamingResourcesImpl`) whose children are + the JNDI entries, shown under their JNDI names. An engine, host or + context that owns a cluster shows a `cluster` node (an inherited + parent cluster is not a child); the cluster shows its `channel` (with + the `membership`, `sender` + `transport`, `receiver` and the + repeatable `interceptor` nodes), the repeatable `clusterValve`, the + `clusterManager`, and the repeatable `clusterListener` and `listener` + nodes. The channel's sub components (`GroupChannel`, the + `McastService` membership, the `NioReceiver`, the `PooledParallelSender` + transport and the interceptors) have no modeler descriptor, so their + common knobs are exposed through an explicit attribute list (the same + mechanism as the TLS components). +- Detail: the class name, the component's attributes rendered as an + editable property list (boolean → checkbox, numeric → numeric input, + `String` → text input, `String[]` → comma separated input; non + writable attributes shown read-only) with a per-property *Apply*, + and a children chip row for drilling down. Changes are applied + immediately to the running server; writing back the current value is + suppressed client-side ("No changes to apply."). The attributes + `name`, `path` and `defaultHost` are treated as risky and require a + type-to-confirm. +- *+ Add* (shown for nodes that can have children) opens a modal with + the child types valid for the selected node (server → service; + service → connector, executor; engine → host, realm, valve, cluster; + host → context, alias, realm, valve, cluster; context → wrapper, + realm, manager, resources, loader, cookieProcessor, valve, cluster; + manager → sessionIdGenerator; connector → sslHostConfig; sslHostConfig → + certificate; cluster → channel, deployer, clusterValve, clusterManager, + clusterListener; channel → membership, sender, receiver, interceptor; + sender → transport; clusterManager → sessionIdGenerator; + namingResources → resource, resourceLink (context only), + resourceEnvRef, environment, ejb, localEjb, serviceRef; and a + `CombinedRealm` node → realm for a sub realm) and + the type's fields. In addition, a `listener` type is offered for + every node whose component implements `Lifecycle` (all structural + types except `alias`, and except the cluster's sub components — only + the `cluster` node itself, not its `channel`/`clusterManager`/..., + offers a `listener`; for a `listener` node the server reports this + as `acceptsListener`, since a listener's class may or may not be a + `Lifecycle`; likewise a realm node offers the `realm` child only when + it reports `acceptsSubRealm`, since only a `CombinedRealm` holds sub + realms). A `valve`, `listener` or `realm` is instantiated from the + class name (public no-arg constructor, server class loader) and + registered with the parent; unlike the structural types a `listener` + is not started. A realm added to a container is started by the + container (which wires it up and drives its lifecycle); a sub realm + is given the combined realm's container and a distinct realm path + (for JMX naming) and started while the combined realm is running. + Nesting is capped at 3 realm levels, the same bound the XML parser + applies. Structural components are started immediately; when the + start fails the addition is rolled back server-side and a controlled + error is returned. +- *TLS*: adding an `sslHostConfig` to a running connector switches it + to TLS without a restart: the endpoint's SSL implementation is + initialised from the configuration (validating the certificates) and + new connections handshake immediately (`createChannel` decides per + connection). The certificate's keystore is validated before the + change is applied; an invalid keystore is rejected and nothing is + changed. Editing a TLS attribute on a running, TLS enabled connector + reloads the affected host configuration at once and reverts the + value when the new one does not validate. Removing the last SSL host + configuration switches the connector back to plain HTTP; because + NIO channels are pooled, the pool is emptied when the SSL flag + changes so no stale (in)secure channel is handed out. + - *Realms*: a container holds at most one realm of its own (adding a + second is 409 `DUPLICATE`). Removing a directly attached realm is + only allowed when the container falls back to a parent realm + (400 `LAST_REALM` otherwise — an engine always keeps a realm). A sub + realm is removed from its combined realm and stopped. Realm + attributes (e.g. `allRolesMode` of a `MemoryRealm`) are edited + through the same property list; invalid values are rejected by the + realm's own setter (400 `SET_FAILED`). + - *Context sub components*: a context always holds exactly one + `manager`, one `resources` root, one `loader` and one + `cookieProcessor` (the defaults are created at context start), and a + manager holds its `sessionIdGenerator`. "Add" for these types is + therefore a **replace**: the current instance is stopped (where it + has a lifecycle) and a new instance of the chosen class is wired in + and started (on a running context/manager; rolled back + + 400 `START_FAILED` when it does not start). Replacing the `resources` + of a running context is refused (400 `CONTEXT_RUNNING`) — the + context must be stopped first. Replacing the `manager` or `loader` + of this webapp's own context is refused (403 `SELF_COMPONENT`). + These components are required and cannot be removed (400 + `REQUIRED_COMPONENT`) — only replaced. Their attributes are edited + through the same property list: the `manager` (`StandardManager`) + and the `resources` (`StandardRoot`) through their modeler + descriptor; the `loader` (`WebappLoader`), the `cookieProcessor` + (`Rfc6265CookieProcessor`) and the `sessionIdGenerator` + (`StandardSessionIdGenerator`) have no modeler descriptor, so their + attribute list is defined explicitly by the servlet (the same + mechanism as the TLS components). + - *JNDI naming resources*: the `namingResources` node of the `Server` + (global, ``) and of a context (the context's + JNDI environment) exposes its JNDI entries — `resource`, + `resourceLink` (context only; not part of the global environment), + `resourceEnvRef`, `environment`, `ejb`, `localEjb` and `serviceRef` — + each keyed by its JNDI name. The `namingResources` node itself is + required and cannot be removed (400 `REQUIRED_COMPONENT`); the + entries are added/removed and the node's `global` flag drives the + client (a server node offers no `resourceLink`). Adding an entry + registers and binds it in the live JNDI environment at once (the + `NamingContextListener` reacts to the add); removing one unbinds it. + An entry's attributes are edited through the property list. Every + entry type extends `ResourceBase`, which carries a generic map of + string parameters (the RefAddr keys the JNDI factories consume at + lookup time); those parameters are shown in the entry detail for + **all** the entry types and can be added (an "Add parameter" form), + edited, or removed (cleared) from the property list. For a + `resource` the closed option set (RefAddr keys) of its effective + first-party factory is additionally rendered as typed fields — the + four shipped factories (`BasicDataSourceFactory`, + `MemoryUserDatabaseFactory`, `DataSourceUserDatabaseFactory`, and the + `PerUserPool`/`SharedPool` data sources) render their options as + typed fields, while any other factory (or an extra key) is a + free-form parameter. Editing an attribute or a `param` re-binds the + entry by removing and re-adding it (so the change is visible to the + live JNDI environment without a restart); renaming a JNDI name is a + risky change (type-to-confirm). + - *Remove* asks for typing the component's display name and is + disabled for the self component. Server-side guards additionally + reject: the last service (`LAST_SERVICE`), the service/engine/host/ + context hosting this webapp (`SELF_COMPONENT`), the basic (first) + valve of a pipeline (`BASIC_COMPONENT`), an engine that still + contains hosts (`NOT_EMPTY`), a directly attached realm whose + container would be left without a realm (`LAST_REALM`), a context's + `manager`/`resources`/`loader`/`cookieProcessor` or a manager's + `sessionIdGenerator` (`REQUIRED_COMPONENT` — required, replace + instead), the default + SSL host configuration of a running, TLS enabled connector while + other configurations remain (`SSL_DEFAULT`) and the last + certificate of a running, TLS enabled connector (`SSL_LAST_CERTIFICATE`). + - *Save to server.xml* fetches the preview (the resulting `server.xml` + plus the external context files that would be rewritten, shown in a + wide modal) and, on confirmation (typing `server.xml`), persists the + live state: a timestamped backup of the previous `server.xml` is + kept. Contexts keep their current storage location, mirroring regular + StoreConfig behaviour — a context backed by its own file + (`META-INF/context.xml` or `conf/Catalina/.../context.xml`) is written + back to that file, a context defined inline stays inline in + `server.xml`. The preview is read-only (external files are captured in + memory, never written). A context restarts when its own file is + written, so a save that rewrites this webapp's own file restarts the + manager and resets the admin session; the dialog warns about this when + it applies (`restartsManager` in the preview response). + +**Users** +- Manages the users, groups and roles of the `UserDatabase` JNDI resources + configured for the server (the default `server.xml` configures the file + based `MemoryUserDatabase` that backs `conf/tomcat-users.xml`). Databases + are discovered through the server's global JNDI naming context; when more + than one is configured a selector in the header chooses which to operate + on (all API calls accept a `name` field/parameter). +- Header card: database type, `read-only` / `not writable` / `writable` + badge, and the JNDI name (plus id). +- When the database is read-only a warning banner explains that + `readonly="false"` must be set on its `Resource` in `server.xml`, and all + mutation controls are disabled. +- Users table: user name (+ full name), roles (badges; roles inherited + through groups shown dimmed with a `+`), groups, row actions *Roles* / + *Password* / *Remove* (remove asks for typing the user name). "Add user" + modal: user name, password, full name, roles (comma separated with + suggestions). +- Groups table: group name, roles, members, row actions *Members* / *Roles* / + *Remove*. "Add group" modal: group name, description, roles. +- Roles table: role name, description, the users and groups that hold the + role, row action *Remove* (removal asks for typing the role name and + detaches the role from all users and groups). "Add role" modal: role name, + description. Roles can also still be created implicitly when assigned to a + user or group; removing the last user or group that uses a role leaves the + (now unused) role defined, matching `UserDatabase` semantics. +- Security notes: passwords are stored exactly as provided (same semantics as + the `password` attribute of `tomcat-users.xml`) and are never returned by + the API; the signed-in account cannot remove itself, nor a role it holds + (directly or through a group); group members must + exist; name values are restricted to a safe character set because they are + persisted in comma separated lists and used as URL path segments. Every + mutation is followed by `save()` so the change is persisted to the storage + of the database. + +**Monitoring** +- Live workers table (5 s cadence): stage, processing time, bytes + sent/received, remote address (forwarded + actual), virtual host, + request line. Stage codes colour-coded; "P/R/K" rows dimmed. +- Connector detail cards with the same data as the Dashboard, plus + max-processing-time history chart. + +**Diagnostics** +- SSL: ciphers / certs / trusted certs tables per connector; "reload SSL + host configs" (all, or single `tlsHostName`). +- Memory leaks: "check now" button, list of suspect contexts. +- JNDI resources: type filter, tree. +- VM: VM info, thread dump (monospace viewer, download as .txt). + +**Logs** +- File selector over the JULI server log files (`catalina`, `localhost`, + `manager`, `host-manager`, `catalina.out`), max-lines selector (500-5000), + refresh button. +- Filters: severity (the levels actually present in the file) and free-text + search. +- Table: time, level (coloured badge), thread, source, message. Row click + opens a drawer with the full record, including the stack trace. +- Both the plain one-line format and the JSON log format + (`org.apache.juli.JsonFormatter`) are handled; the format is detected per + file from the first line. + +**Access log** +- File selector over the access log files, max-lines selector, refresh + button. +- Filters that are shown depend on the fields the configured format + provides: method, status (class `1xx`-`5xx` or exact code), user, session + ID and free-text search. A filter is only offered when its field is part + of the configured format (e.g. no session-ID filter when the pattern has + no `%S`). +- The pattern based format is parsed with the pattern of the configured + `AccessLogValve` (falling back to the common and combined patterns when no + valve is configured), and the JSON format + (`org.apache.catalina.valves.JsonAccessLogValve`) as one JSON object per + line. Both formats expose the same field names, and the method / path / + query / protocol are derived from the request line when only `%r` is + logged. +- Table: host, user, time, request (or the derived method / path / query / + protocol), status (coloured badge), size, session ID (when logged). Row + click opens a drawer with the full record. + +### 5.3 Design system + +- Plain CSS with custom properties (no framework); light + dark themes + (system preference default, manual toggle, persisted in + `localStorage`). +- System font stack; 12-column grid; 8 px spacing scale; single accent + colour; states (success/warning/danger) with colour + icon (never colour + alone). +- Responsive: tables convert to stacked cards below 768 px; charts + re-flow to a single column; actions move into an overflow menu. +- Accessibility (WCAG 2.1 AA): semantic landmarks, visible focus states, + keyboard-operable modals/drawers (focus trap, `Esc` closes), + `aria-live="polite"` toasts, live chart updates announced at reduced + frequency, contrast ≥ 4.5:1. + +## 6. Live updating + +- **Default transport: HTTP polling.** Dashboard 2 s, Monitoring 5 s, + Applications list 10 s; all polling pauses when `document.hidden` and + resumes on focus. Intervals are servlet init-params + (`pollIntervalStatus`, …) and client-configurable. +- **Why not WebSockets/SSE first:** polling is proxy/LoadBalancer-safe, + stateless, and the payload is tiny; the MBean attribute reads done per + poll are the same ones the legacy status page did per full page load. + A `GET /api/stream` (Server-Sent Events) endpoint is specified as an + optional future extension behind an init-param. +- **Charting:** hand-written canvas module (`js/charts.js`): rolling + ring-buffer per series, auto-scaling y-axis, 1 s/5 s/15 min window + switcher, hover readout, optional log scale for byte counters. Line + charts, area charts and gauges only — no external chart library. +- **Data integrity:** every snapshot carries `ts`; the client discards + out-of-order duplicates and renders a gap marker if a poll is missed. + Counter deltas that go backwards (JVM/connector restart) reset the rate + calculation instead of producing negative rates. + +## 7. Security + +### 7.1 Authentication — HTTP FORM + +`WEB-INF/web.xml` (replacing the legacy `BASIC` login-config): + +```xml + + FORM + Tomcat Manager2 Application + + /login + /login?error=1 + + +``` + +- Works with any configured Realm (default file realm, JDBC, LDAP, …); + credentials are sent once over the session, not in every request header. +- The login page is rendered by `LoginServlet` (not served as a static + resource) for three reasons: + - it injects a `` element, because the page can be displayed at + arbitrary URLs (the browser keeps the URL of the request that triggered + the forward) and the relative CSS link would otherwise break; + - it normalizes the FORM-authentication *saved request* so the + post-login redirect lands inside the application instead of at the + last unauthenticated request, which for a single-page application is + frequently a CSS or JS file or a JSON API call. Concretely: when the + login page is being rendered for an SPA route (the deep-link gate in + `HomeServlet` forwards with the route URI still on the request), the + saved request is pointed at that route, so the user returns to exactly + the page they were on; any other saved request (an API call, an asset, + ...) is replaced with a GET of the context root (`/`). The root URL + (not `/index.html`) keeps the SPA router on its dashboard route; + `HomeServlet` serves the shell there. + - it creates the session (and records its ID, mirroring what the + authenticator does when it changes the session ID) so the login form + submission is tied to a session even when the browser arrived at the + login page without one. +- `LoginServlet` also sets the `FormAuthenticator` landing page to the + context root (`/`) as a safety net for logins without a saved request. +- The session cookie (`JSESSIONID`) is `HttpOnly`; deployments are + expected to serve manager2 over TLS so the cookie is `Secure`. The + context uses the RFC 6265 cookie processor with `sameSiteCookies="strict"` + (see §8). + +### 7.2 Authorization — per-endpoint roles + +Same role names as the legacy webapps (no new roles to document; a +`manager2-*` naming scheme is an open question, §12): + +```xml + + + Read-only status + /api/status + /api/status/* + /api/info + /api/csrf + + + manager-gui + manager-status + + + + + + Manager API + /api/* + + + manager-gui + + +``` + +`manager-status` users get read-only monitoring; `manager-gui` users get +everything. + +The SPA shell and its static assets (`/`, `/index.html`, `/css/*`, `/js/*`, +`/img/*`, the login and error pages) are **not** protected with a security +constraint. A constraint with the URL pattern `/` matches *every* request in +the context (not just the context root), so protecting the shell with `/` +would also catch the login page's own CSS and JS: they would be sent through +FORM authentication (leaving the login page unstyled) and would poison the +saved request (sending the browser to a CSS file after login). Instead: + +- `HomeServlet` gates the SPA entry point (`/`) and the SPA deep-link routes + (`/apps`, `/hosts`, `/configuration`, `/users`, `/monitoring`, + `/diagnostics`, `/logs`, `/access-log`, `/apps/*`): it forwards + unauthenticated visitors to the login page and authenticated users to the + shell, preserving the requested URL so deep links survive a reload. +- The context root *without* a trailing slash (e.g. `/manager2`) is + redirected (302) to the trailing-slash form by `HomeServlet`. Without the + redirect the browser would resolve the page's relative URLs (`css/*`, + `js/*`, `images/*`) against the server root instead of the context, + breaking the page. The mapper's own context-root redirect + (`Context#setMapperContextRootRedirectEnabled`, on by default) cannot do + this here: it only applies when *no* servlet is mapped to the context + root, but `HomeServlet` is (via the empty URL pattern, which the mapper + registers as the exact match `/`), so the mapper always finds a wrapper + before that redirect branch is reached. +- The JSON API is the only place where data lives and the only place that is + constraint-protected. The SPA itself reacts to an unauthenticated API + response (the container forwards the XHR to the login page) by performing a + full navigation to the *current* URL (not the application root): the server + then gates that page to the login page at the same URL, and a successful + login returns the user to the page they were on. A short guard suppresses a + second bounce within a few seconds (falling back to the root) so a bad + credential cannot spin a reload loop. Note that error responses (4xx/5xx, + including the container's HTML error page) are surfaced to the page and are + *not* treated as an unauthenticated condition. + + This matters when the session is lost while the user is mid-application + (e.g. the webapp is redeployed, which destroys all in-memory sessions): the + user is re-prompted for credentials at the page they were on rather than + being dropped at the root. + +### 7.3 CSRF protection + +Synchronizer-token pattern, implemented in a small `CsrfFilter` +(`org.apache.tomcat.manager2.CsrfFilter`) mapped to `/api/*`: + +1. On first API access (or after login) the filter generates a 128-bit + `SecureRandom` token (32-char hex) and stores it in the `HttpSession`. +2. The token is delivered to the SPA in the **`X-CSRF-Token` response + header of every API response** (so it is always fresh after a session + change) and also via `GET /api/csrf`. It is never placed in URLs or + query strings. +3. Every non-safe request (`POST`, `PUT`, `DELETE`, `PATCH`) must carry the + token in an `X-CSRF-Token` request header. The SPA's `api.js` wrapper + reads the header from the last response and attaches it automatically; + missing or mismatched token → `403` with `error: "CSRF"` and an audit + log entry. +4. Defence in depth: all mutation endpoints require + `Content-Type: application/json` (except the multipart upload) and the + webapp sets no `Access-Control-Allow-Origin` headers, so a cross-origin + page cannot make a simple cross-site request succeed — the browser + preflight is rejected. The token covers the cases browsers do not + (e.g. same-site subdomain hosting, non-browser clients that should be + rejected). +5. The filter additionally requires an authenticated session for unsafe + methods (redundant with the security constraints, but it gives a clean + 403/401 split and a single audit point). + +This is functionally equivalent to the `CsrfPreventionFilter` the legacy +webapp already applies to `/html/*`, but adapted to a header-based JSON +client instead of a hidden form field. + +### 7.4 Security headers + +`HttpHeaderSecurityFilter` mapped to `/*`, with the new default +`hstsEnabled=true` (overridable, like the legacy app but safer by +default) plus an explicit CSP: + +``` +Content-Security-Policy: default-src 'self'; script-src 'self'; + style-src 'self'; img-src 'self' data:; connect-src 'self'; + frame-ancestors 'none'; base-uri 'self'; form-action 'self' +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +``` + +No inline scripts/styles exist, so no `'unsafe-inline'` is needed. +Note: `style-src 'self'` also makes the browser **drop style attributes +set through `setAttribute('style', ...)`** (they count as inline style +and are blocked, with a console violation) — while the CSS object model +(`el.style.cssText = ...`) is unaffected. The `el()` helper therefore +applies a `style` attribute through `node.style.cssText`; any new code +must keep doing so or the style is silently ignored. + +### 7.5 Request-safety invariants (inherited from the legacy servlets) + +- WAR upload: 50 MiB `multipart-config` cap, `.war` suffix validation, + submitted file name reduced to its base name, destination confined to + the host's `appBase` via canonical-path containment check, update path + writes `*.war.tmp` first to avoid auto-deploy races, per-context + `tryAddServiced` guard against concurrent deployments. +- Server-side deploy: WAR/config paths are confined to `appBase` / + `conf` via the same containment checks. +- Context name validation (leading `/`, no `..`, version syntax) as in + `ManagerServlet.validateContextName`. +- The servlet refuses to undeploy/reload the context it runs in; the host + it runs in cannot be stopped or removed. manager2 must therefore be + deployed in its own context (default: `/manager2`) — documented. +- All output is JSON produced by the API layer; there is no HTML + rendering of user/server data, so no escaping surface. The thread-dump + and VM-info endpoints return plain text in a JSON string, rendered in a + read-only `
` (never `innerHTML`-ed from data).
+
+### 7.6 Audit logging
+
+Every mutation is logged at INFO by the API layer, including: timestamp,
+authenticated principal, remote address, action, target (host/context),
+parameters that are not secrets, and outcome (ok/error code). This is
+strictly more auditable than the legacy `debug`-level logging and is the
+single place to hook external audit sinks later.
+
+### 7.7 Known limitation
+
+Tomcat does not change the session id at successful FORM login by default,
+so session-fixation protection relies on the login happening in a fresh
+anonymous session. The howto will recommend serving manager2 over TLS with
+`SameSite=Lax` session cookies; a session-id-change valve is listed as a
+possible follow-up rather than a requirement.
+
+## 8. Packaging and build
+
+**Design decision (implemented):** everything lives in a standalone module
+at `modules/manager2` with its own Ant build and packaging, instead of being
+merged into the main tree's `catalina.jar` + `webapps/` copy. The module is
+fully decoupled: it compiles against the main build's jars, produces its own
+`manager2.jar` + `manager2.war`, and its `deploy` target drops the WAR into
+the main build's `webapps/` directory.
+
+Module layout:
+
+```
+modules/manager2/
+  build.xml                 standalone Ant build (jar, war, deploy, test)
+  build.properties.default  version + main-build location
+  resources/MANIFEST.MF     jar manifest
+  src/main/java/org/apache/tomcat/manager2/
+    AppsApiServlet.java       extends HTMLManagerServlet
+    HostsApiServlet.java      extends HostManagerServlet
+    StatusApiServlet.java     status endpoints
+    StatusSnapshot.java       MBean collection → JSON model
+    LogsApiServlet.java       /api/logs + /api/access-log (list, tail, filters)
+    LogParser.java            JULI text/JSON log lines, access log pattern→regex
+    AccessLogSupport.java     access log field names, normalization
+    UsersApiServlet.java      /api/users + /api/groups + /api/roles
+                               (UserDatabase JNDI discovery,
+                               user/group/role management, save)
+    CsrfFilter.java           CSRF token issue/verify
+    HeadersFilter.java        CSP / Referrer-Policy
+    Strings.java              StringManager creation that finds the web app's
+                              LocalStrings bundle regardless of the initializing
+                              thread's context class loader
+    Api.java, Json.java, Constants.java
+    LocalStrings.properties
+   src/test/java/org/apache/tomcat/manager2/
+     TestManager2Webapp.java
+     TestManager2Config.java
+  webapp/                   SPA shell, login, css, js, WEB-INF/web.xml,
+                            META-INF/context.xml (privileged context)
+```
+
+Notes:
+
+1. The package is `org.apache.tomcat.manager2`, not
+   `org.apache.catalina.manager2`: `DefaultInstanceManager` always loads
+   `org.apache.catalina*` classes with the container class loader, which
+   would make the web app's own jar unreachable.
+2. The WAR is self-contained (`WEB-INF/lib/manager2.jar`), following the
+   convention of the other `modules/` web apps.
+3. No main-tree build changes at all; no new Ant dependencies, no new jars,
+   no license/NOTICE changes (zero third-party code).
+
+`conf/` changes: none required to *use* the webapp; existing role names
+(`manager-gui`, `manager-status`) are reused. To make the Users page
+*persist* changes, the `UserDatabase` resource must be writable: the file
+based database defaults to read-only, so `readonly="false"` has to be added
+to its `` definition in `server.xml` (and the server restarted).
+Until then the page still shows the current users, groups and roles, the
+mutation controls are disabled, and a banner explains what to configure.
+
+## 9. Testing
+
+New `modules/manager2/src/test/java/org/apache/tomcat/manager2/
+TestManager2Webapp.java` (`TomcatBaseTest`, run by the module's `ant test`),
+modelled on the existing `TestManagerWebapp` but exercising the new flows:
+
+1. **FORM auth flow** with `SimpleHttpClient`: unauthenticated `GET
+   /manager2/api/apps` → redirect to `login.html`; `POST
+   j_security_check` with the session cookie → 302 back; authenticated
+   call → 200. The context root without a trailing slash (`/manager2`)
+   is a 302 redirect to `/manager2/` (see §7.2).
+2. **CSRF flow**: `POST /api/hosts` (or any mutation) without token → 403
+   `CSRF`; with the `X-CSRF-Token` header → 200. Re-login invalidates the
+   old token.
+3. **Authorization**: user with only `manager-status` can `GET
+   /api/status` but gets 403 on `/api/apps` and all mutations;
+   unassigned user gets 401/redirect on everything.
+4. **Functional parity** (same test WARs as `TestManagerWebapp`): list,
+   deploy (server-side + upload), start/stop/reload/undeploy, session
+   list/sort/detail/invalidate/attribute-removal, expire idle, global JNDI
+   resources (asserts the classic manager's human readable status header is
+   stripped from the `name:class` lines).
+5. **Hosts**: add (all parameters), list (asserts the running default host
+   is reported `started: true`, not matched on the raw state name), stop,
+   start, remove, persist.
+6. **Status**: `GET /api/status`, `/api/status/workers`,
+   `/api/status/apps/{path}` — assert the JSON contract (key presence,
+   types, connector names) rather than exact values; counters
+   monotonically increase across two polls.
+ 7. **Headers**: `X-Frame-Options: DENY`, CSP, `X-CSRF-Token` presence on
+    responses, `Cache-Control: no-store` on API responses.
+ 8. **Users**: with no `UserDatabase` JNDI resource configured, `GET
+    /api/users` and mutations return 404 `USER_DATABASE_MISSING`. With a
+    file based `MemoryUserDatabase` registered on the global naming context
+    (via `Tomcat.enableNaming()` + `ContextResource`): list (users, groups,
+     roles, `readonly`/`writable` flags), create user with roles, change
+     password, replace roles, create group, set members, replace group
+     roles, create role with description, remove role (detaching it from
+     the users that hold it), remove group/user — each verified against the
+     persisted XML file; duplicate names → 409, unknown members → 400
+     `UNKNOWN_GROUP_MEMBER`, unknown user/group/role → 404, self removal →
+     400 `SELF_REMOVAL`, self role removal (a role held by the signed-in
+     account) → 400 `SELF_ROLE_REMOVAL`; a read-only database
+     (`readonly` attribute not set) rejects all mutations with 400
+     `USER_DATABASE_READONLY`; `manager-status` gets 403 on the users API.
+ 9. **Logs**: list (JULI + access log files, detected format), tail with
+    level / method / status / user / session / free-text filters (text and
+    JSON formats, pattern driven access log fields), and the returned
+    records are asserted to be the most recent matching lines ordered from
+    most recent to least recent. `manager-status` gets 403.
+10. **Deploy wizard (browser E2E)**: the modal opens with only the
+    *Upload* pane visible; *From server* shows only its pane; switching
+    back to *Upload* shows the complete upload form again (regression for
+    the "truncated form" report, which had two causes: the server pane
+    lacked an initial `display:none`, and the CSP blocked `style`
+    attributes set via `setAttribute` — see §7.4).
+11. **Configuration** (`TestManager2Config`): tree shape (the service,
+    engine and host are resolved from the returned tree by type — their
+    names depend on how the test instance was created); node details
+    (id/type/className/properties, the `self` flag on the self context,
+    404 for unknown nodes); attribute round-trip (read, update, read
+    back, restore) plus guards (non-writable attribute → 400 `READ_ONLY`,
+    unknown attribute → 404 `ATTRIBUTE_NOT_FOUND`, value that does not
+    convert to the attribute type → 400 `INVALID_VALUE`, the self
+    context's `path` → 403 `SELF_COMPONENT` with or without a confirm);
+     add + remove of all eight structural child types (service,
+     connector, executor, host, alias, context, wrapper, valve) with the
+     whole branch visible in the tree in between, and the guards
+     (unsupported type → 400 `UNSUPPORTED_TYPE`, wrong parent → 400
+     `BAD_PARENT`, duplicate service name → 409 `DUPLICATE`, invalid
+     connector port → 400 `INVALID_VALUE`, executor
+     `minSpareThreads > maxThreads` → 400 `INVALID_VALUE`, last service →
+     400 `LAST_SERVICE`, self service/host/context → 403
+     `SELF_COMPONENT`, basic valve → 400 `BASIC_COMPONENT`); add +
+     remove of a `listener` on a `Lifecycle` parent (visible in the tree,
+     node detail resolves, non-listener or unknown class → 400
+     `INVALID_CLASS`, alias parent → 400 `BAD_PARENT`); `store/preview`
+     returns the XML without writing
+    anything; `store` writes `conf/server.xml` containing the live state
+    (including a just-added alias) and keeps a timestamped backup.
+     Unauthenticated tree request → the login page; `manager-status` →
+     403; a mutation without a CSRF token → 403. The root context (empty
+     path) is addressed by a bare `+` segment and displayed as `/`: its
+     node detail and its child components (a wrapper added through the API)
+     both resolve. TLS on a dedicated service (so it never is the connector
+     of this webapp): guards (wrong parent → 400 `BAD_PARENT`, no
+     certificate → 400 `INVALID_VALUE`, unloadable keystore → 400
+     `ADD_FAILED` + rollback, unknown certificate type → 400
+     `INVALID_NAME`, certificate outside an SSL host configuration →
+     400 `BAD_PARENT`); add an SSL host configuration with its
+     certificate (the connector serves real TLS, verified with a TLS
+     handshake, and reports `sslEnabled`), node details of the host
+     configuration and the certificate (including the `protocols`
+     property, compared order-independently), attribute update
+     (`protocols`) and read-only protection (`type`, `hostName`),
+     adding and removing a second certificate, the
+     `SSL_LAST_CERTIFICATE` guard, the TLS branch in the stored
+     `server.xml` (including the keystore path), the duplicate host
+     name guard (409 `DUPLICATE`), and removal (the connector serves
+     plain HTTP again, verified with a plain request). Realms: the
+     engine realm is shown and an inherited realm is not; node detail
+     (modeler properties, `acceptsSubRealm` false for a plain realm);
+     add a realm to a host (unknown class → 400 `INVALID_CLASS`, a
+     second one → 409 `DUPLICATE`), attribute update (`allRolesMode`,
+     invalid value → 400 `SET_FAILED`), a `LockOutRealm` on a context
+     with sub realms (`acceptsSubRealm` true, adding two, both shown,
+     node detail of a sub realm, the combined realm and its sub realms
+      in the stored `server.xml`), removing a sub realm and the
+      combined realm, the `LAST_REALM` guard on the engine realm, and
+      removing the host realm (falling back to the engine realm).
+      Context sub components on a dedicated context: the manager (with
+      its session id generator), resources, loader and cookie processor
+      are shown; node detail (the manager and resources through their
+      modeler descriptor, the loader, cookie processor and session id
+      generator through the explicit attribute list); attribute updates
+      (`maxActive`, `allowLinking`, `delegate`, `sameSiteCookies`,
+      `sessionIdLength`, invalid value → 400 `SET_FAILED`); replace via
+      add (a fresh instance with the defaults is in place, the
+      manager/loader keep their `STARTED` state, the session id
+      generator is re-created), wrong class → 400 `INVALID_CLASS`,
+      wrong parent → 400 `BAD_PARENT`, replacing the self context's
+      manager or loader → 403 `SELF_COMPONENT`, replacing the
+      resources of a running context → 400 `CONTEXT_RUNNING` and on a
+      stopped context → 200 (started again afterwards), and the
+       `REQUIRED_COMPONENT` guard on removal of all five types.
+       JNDI naming resources (with naming enabled via `Tomcat.enableNaming()`):
+       the server's global `namingResources` node (`global: true`) and a
+       context's (`global: false`); add a global `UserDatabase` with a
+       first-party factory (the closed factory options are shown as `param`
+       properties with their values, a not-set option is listed with a null
+        value) and verify it is bound in the live global naming context (and
+        loaded its users); add an `environment`, `ejb`, `localEjb` and
+        `serviceRef` (all shown in the tree); the generic string parameters
+        of the `environment`, `ejb` and `serviceRef` entries (the
+        `ResourceBase` property map) are shown as `param` properties and can
+        be added, edited and removed (cleared); the guards (duplicate JNDI name
+       → 409 `DUPLICATE`, missing `jndiType` → 400 `MISSING_FIELD`,
+       `resourceLink` at the server level → 400 `BAD_PARENT`, unloadable
+       factory → 400 `INVALID_CLASS`, removing the node → 400
+       `REQUIRED_COMPONENT`); a parameter update re-binds the entry (the new
+       value is reported and the live lookup still resolves); renaming a JNDI
+       name requires a confirm and re-binds it (the old name is unbound, the
+       new name is bound); a free-form parameter is added and then cleared
+       (empty value removes it); the global entries round-trip to
+       `` in the store preview (including the
+       ``); and a context-level `resourceLink` (shown with its
+       `global`) and a `resource` whose type dispatches to the default
+       data-source factory (bound in the context's live environment, its
+       factory options editable as parameters, the pool size update re-binds
+       it, duplicate → 409, removal unbinds it).
+
+    The browser E2E for this page (login, tree, property edit, add/remove
+    with confirm, save preview, no console errors) is driven over CDP and
+    is not part of `ant test`.
+
+Test-environment notes (discovered while implementing):
+
+- The programmatic test instance has no global `conf/web.xml` and
+  `setAddDefaultWebXmlToWebapp(false)` is set, so the test webapps must
+  declare their own `default`/`jsp` servlets and welcome files in
+  `web.xml`; `StandardContext.stop()` resets all wrappers and only
+  re-creates them from `web.xml` on start, which is what makes
+  stop/start/undeploy testable at all.
+- The test instance's default host has no `HostConfig`, so the
+  `Catalina:type=Deployer,host=...` MBean (the `Deployer` the deploy API
+  uses) is absent; the test attaches one as a host lifecycle listener.
+- `SimpleHttpClient` request parts must keep their CRLF terminators and the
+  client must re-`connect()` before every request (`Connection: Close`).
+- JNDI naming is disabled by default in the programmatic `Tomcat`, so the
+  users tests call `Tomcat.enableNaming()` before start; the test then
+  registers a `MemoryUserDatabase` on the server's global naming resources
+  exactly like the `` of the default `server.xml` does.
+- The `StringManager` cache is JVM-wide and the first creation for a
+  package wins; a class initialized by a non-request thread (e.g. the
+  filter initialization during context start) would otherwise cache a
+  manager that cannot see the web app's `LocalStrings` bundle. `Strings`
+  creates the manager with the web app's own class loader as the context
+  class loader, so the first creation always finds the bundle.
+- The programmatic `Tomcat` API installs a private internal realm
+  (`Tomcat$SimpleRealm`) on the engine that `storeconfig` cannot
+  serialise (`StoreAppender` instantiates the realm class via its public
+  no-arg constructor), and names the default service *and* engine
+  `Tomcat` rather than `Catalina`. `TestManager2Config` therefore swaps
+  in a `MemoryRealm` — the same type the production `server.xml` uses —
+  loaded from a minimal `conf/tomcat-users.xml` written into the test's
+  temporary `catalina.base` (a bare `MemoryRealm`, such as the one
+  `addService` creates for new services, requires that file to exist to
+  start), and resolves every node path from the returned tree instead of
+  hard-coding service/engine names.
+- `SimpleHttpClient` cannot read chunked responses: a response larger
+  than the connector's 8 KB buffer that has no content length is
+  committed mid-write and sent with `Transfer-Encoding: chunked`, which
+  the client then reads as raw framing. The manager2 API therefore
+  always sets the content length on its JSON responses (the body is
+  already built in memory), which the node-details test exercises
+  (the context node detail is well over 8 KB).
+- `org.apache.tomcat.util.json.JSONParser` (the shared request-body
+  parser) recognised the JSON escape sequences of a string token but
+  never resolved them, so any parsed string kept its backslashes
+  (`"a\"b"` parsed as `a\"b`). This was fixed in the parser (both the
+  generated class and the `.jjt` grammar): the string body is now
+   unescaped (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`). The
+   configuration store-preview test is the first assertion that parses
+   a response whose value contains quotes (the stored `server.xml`),
+   and it failed until the fix.
+ - `storeconfig` parsed a `` (a JNDI `serviceRef` entry) but
+   could not store it back: `NamingResourcesSF` did not write the services
+   and the registry had no `` description. A two-line core
+   change (a `ServiceRef` registry description for `ContextService`, and
+   storing `findServices()` in `NamingResourcesSF.storeChildren`) closes
+   the round-trip, so a `serviceRef` added through the Configuration page
+   is written to `server.xml` like the other JNDI entries. The other six
+   entry types already round-tripped.
+
+Frontend: a manual smoke-test checklist in the docs (login, each page,
+deploy upload, live chart behaviour, mobile widths); the JS is small
+enough that a lint pass (`--check` via a CI node step, optional) plus the
+integration tests above gives adequate coverage without a JS test
+harness.
diff --git a/modules/manager2/resources/MANIFEST.MF b/modules/manager2/resources/MANIFEST.MF
new file mode 100644
index 000000000000..6dc136355c36
--- /dev/null
+++ b/modules/manager2/resources/MANIFEST.MF
@@ -0,0 +1,6 @@
+Manifest-Version: 1.0
+Bundle-Vendor: Apache Software Foundation
+Bundle-Version: @VERSION@
+Bundle-Name: Apache Tomcat Manager2
+Bundle-ManifestVersion: 2
+Bundle-SymbolicName: org.apache.tomcat.manager2
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java
new file mode 100644
index 000000000000..f0e4c82984f7
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You 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.
+ */
+package org.apache.tomcat.manager2;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * Shared helpers for access log records, independent of the format that produced them. The field names are the
+ * attribute names of {@code org.apache.catalina.valves.JsonAccessLogValve}, so that the pattern based and the JSON
+ * access log format expose the same fields.
+ */
+final class AccessLogSupport {
+
+
+    /**
+     * The directive to regular expression and field name mapping. The regular expressions are capturing groups; the
+     * character values that the valve writes for "not available" ({@code -}) are accepted by the numeric ones.
+     */
+    private static final Map DIRECTIVES;
+    static {
+        Map map = new HashMap<>();
+        map.put('a', new String[] { "(\\S+)", "remoteAddr" });
+        map.put('A', new String[] { "(\\S+)", "localAddr" });
+        map.put('b', new String[] { "(-?\\d+|-)", "size" });
+        map.put('B', new String[] { "(-?\\d+|-)", "byteSentNC" });
+        map.put('D', new String[] { "(\\d+)", "elapsedTime" });
+        map.put('F', new String[] { "(\\d+)", "firstByteTime" });
+        map.put('h', new String[] { "(\\S+)", "host" });
+        map.put('H', new String[] { "(\\S+)", "protocol" });
+        map.put('I', new String[] { "(\\S+)", "threadName" });
+        map.put('l', new String[] { "(\\S+)", "logicalUserName" });
+        map.put('m', new String[] { "(\\S+)", "method" });
+        map.put('p', new String[] { "(\\d+)", "port" });
+        map.put('q', new String[] { "(\\S*)", "query" });
+        map.put('r', new String[] { "([^\\\"]*)", "request" });
+        map.put('s', new String[] { "(\\d{3}|-)", "statusCode" });
+        map.put('S', new String[] { "(\\S+)", "sessionId" });
+        map.put('t', new String[] { "(\\[[^\\]]*\\])", "time" });
+        map.put('T', new String[] { "(\\d+(?:\\.\\d+)?)", "elapsedTimeS" });
+        map.put('u', new String[] { "(\\S+)", "user" });
+        map.put('U', new String[] { "(\\S+)", "path" });
+        map.put('v', new String[] { "(\\S+)", "localServerName" });
+        map.put('X', new String[] { "(\\S+)", "connectionStatus" });
+        DIRECTIVES = Collections.unmodifiableMap(map);
+    }
+
+
+    /**
+     * The field name prefix of the keyed directives ({@code %{name}x}).
+     */
+    private static final Map KEYED_PREFIXES;
+    static {
+        Map map = new HashMap<>();
+        map.put('a', "remoteAddr");
+        map.put('c', "cookie");
+        map.put('i', "header");
+        map.put('L', "identifier");
+        map.put('o', "responseHeader");
+        map.put('p', "port");
+        map.put('r', "requestAttribute");
+        map.put('s', "sessionAttribute");
+        map.put('t', "time");
+        KEYED_PREFIXES = Collections.unmodifiableMap(map);
+    }
+
+
+    private AccessLogSupport() {
+        // Utility class, do not instantiate
+    }
+
+
+    static Map directives() {
+        return DIRECTIVES;
+    }
+
+
+    /**
+     * The field name of a keyed directive or {@code null} if the directive is not supported.
+     */
+    static String keyedField(char directive, String key) {
+        String prefix = KEYED_PREFIXES.get(directive);
+        if (prefix == null) {
+            return null;
+        }
+        return prefix + "-" + key;
+    }
+
+
+    /**
+     * The ordered list of all field names a record can have: the fields of the capture groups plus, when the request
+     * line is logged but not the method, path, query or protocol individually, the fields derived from it (inserted
+     * directly after the request).
+     */
+    static List displayFields(List groupFields) {
+        List fields = new ArrayList<>(groupFields);
+        if (fields.contains("request")) {
+            boolean missing = !fields.contains("method") || !fields.contains("path") || !fields.contains("query") ||
+                    !fields.contains("protocol");
+            if (missing) {
+                int at = fields.indexOf("request") + 1;
+                List derived = new ArrayList<>();
+                for (String name : new String[] { "method", "path", "query", "protocol" }) {
+                    if (!fields.contains(name)) {
+                        derived.add(name);
+                    }
+                }
+                fields.addAll(at, derived);
+            }
+        }
+        return fields;
+    }
+
+
+    /**
+     * Normalize a parsed record: convert the common {@code -} marker to {@code null}, remove the square brackets of the
+     * time and convert the numeric fields to numbers.
+     */
+    static void normalize(Map record) {
+        Object time = record.get("time");
+        if (time instanceof String t && t.startsWith("[") && t.endsWith("]")) {
+            record.put("time", t.substring(1, t.length() - 1));
+        }
+        for (Map.Entry entry : record.entrySet()) {
+            if (entry.getValue() instanceof String s) {
+                if ("-".equals(s)) {
+                    entry.setValue(null);
+                } else {
+                    entry.setValue(numberOf(entry.getKey(), s));
+                }
+            }
+        }
+    }
+
+
+    /**
+     * Split the request line of a record into method, path, query and protocol when those fields are not present
+     * individually.
+     */
+    static void deriveFromRequest(Map record) {
+        if (!(record.get("request") instanceof String request) || record.containsKey("method")) {
+            return;
+        }
+        int i1 = request.indexOf(' ');
+        if (i1 <= 0) {
+            return;
+        }
+        String method = request.substring(0, i1);
+        int i2 = request.indexOf(' ', i1 + 1);
+        String target = i2 >= 0 ? request.substring(i1 + 1, i2) : request.substring(i1 + 1);
+        String protocol = i2 >= 0 && i2 + 1 < request.length() ? request.substring(i2 + 1) : null;
+        record.putIfAbsent("method", method);
+        int q = target.indexOf('?');
+        if (q >= 0) {
+            record.putIfAbsent("path", target.substring(0, q));
+            record.putIfAbsent("query", target.substring(q + 1));
+        } else {
+            record.putIfAbsent("path", target);
+        }
+        if (protocol != null) {
+            record.putIfAbsent("protocol", protocol);
+        }
+    }
+
+
+    private static Object numberOf(String key, String value) {
+        switch (key) {
+            case "statusCode": {
+                try {
+                    return Integer.valueOf(value);
+                } catch (NumberFormatException e) {
+                    return value;
+                }
+            }
+            case "port":
+            case "elapsedTime":
+            case "firstByteTime": {
+                try {
+                    return Long.valueOf(value);
+                } catch (NumberFormatException e) {
+                    return value;
+                }
+            }
+            case "size":
+            case "byteSentNC": {
+                try {
+                    return Long.valueOf(value);
+                } catch (NumberFormatException e) {
+                    return value;
+                }
+            }
+            case "elapsedTimeS": {
+                try {
+                    return Double.valueOf(value);
+                } catch (NumberFormatException e) {
+                    return value;
+                }
+            }
+            default:
+                return value;
+        }
+    }
+}
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java
new file mode 100644
index 000000000000..6a7fa2f9cde3
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.tomcat.manager2;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+
+/**
+ * Shared helpers for writing the JSON responses of the Manager2 API.
+ */
+public final class Api {
+
+
+    /**
+     * Write an arbitrary JSON payload (a {@code Map} produced by the API layer).
+     *
+     * @param response the servlet response
+     * @param payload  the JSON payload
+     * 
+     * @throws IOException if a write error occurs
+     */
+    public static void json(HttpServletResponse response, Object payload) throws IOException {
+        response.setStatus(HttpServletResponse.SC_OK);
+        headers(response);
+        String body = Json.write(payload);
+        // Set the content length so that the response is never sent with
+        // chunked transfer encoding, which breaks simple HTTP clients.
+        response.setContentLength(body.getBytes(StandardCharsets.UTF_8).length);
+        response.getWriter().print(body);
+    }
+
+
+    /**
+     * Write a successful mutation result envelope.
+     *
+     * @param response the servlet response
+     * @param message  the (localized) result message
+     * 
+     * @throws IOException if a write error occurs
+     */
+    public static void ok(HttpServletResponse response, String message) throws IOException {
+        Map payload = new LinkedHashMap<>();
+        payload.put("ok", Boolean.TRUE);
+        payload.put("message", message);
+        json(response, payload);
+    }
+
+
+    /**
+     * Write an error envelope.
+     *
+     * @param response the servlet response
+     * @param status   the HTTP status code
+     * @param code     the machine readable error code
+     * @param message  the (localized) error message
+     * 
+     * @throws IOException if a write error occurs
+     */
+    public static void error(HttpServletResponse response, int status, String code, String message) throws IOException {
+        response.setStatus(status);
+        headers(response);
+        Map payload = new LinkedHashMap<>();
+        payload.put("ok", Boolean.FALSE);
+        payload.put("error", code);
+        payload.put("message", message);
+        String body = Json.write(payload);
+        response.setContentLength(body.getBytes(StandardCharsets.UTF_8).length);
+        response.getWriter().print(body);
+    }
+
+
+    /**
+     * Write a 404 error envelope.
+     *
+     * @param response the servlet response
+     * 
+     * @throws IOException if a write error occurs
+     */
+    public static void notFound(HttpServletResponse response) throws IOException {
+        error(response, HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", "Resource not found");
+    }
+
+
+    private static void headers(HttpServletResponse response) {
+        response.setContentType("application/json; charset=" + Constants.CHARSET);
+        response.setCharacterEncoding(Constants.CHARSET);
+        response.setHeader("Cache-Control", "no-store");
+    }
+
+
+    private Api() {
+        // Utility class, do not instantiate
+    }
+}
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java
new file mode 100644
index 000000000000..dd029add5733
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java
@@ -0,0 +1,772 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.tomcat.manager2;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Serial;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import jakarta.servlet.http.Part;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.Container;
+import org.apache.catalina.Session;
+import org.apache.catalina.manager.HTMLManagerServlet;
+import org.apache.catalina.manager.JspHelper;
+import org.apache.catalina.util.ContextName;
+import org.apache.tomcat.util.json.JSONParser;
+import org.apache.tomcat.util.res.StringManager;
+
+
+/**
+ * The Manager2 web application API. Delegates the actual operations to the inherited implementation from
+ * {@link HTMLManagerServlet} and {@link org.apache.catalina.manager.ManagerServlet}, and exposes them as a JSON API.
+ */
+public class AppsApiServlet extends HTMLManagerServlet {
+
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+
+    /**
+     * The string manager for this package.
+     */
+    protected static final StringManager sm = Strings.manager();
+
+
+    @Override
+    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        try {
+            doGetInternal(request, response);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void doGetInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+        String path = path(request);
+
+        if (path.equals("/api/apps")) {
+            Map payload = new LinkedHashMap<>();
+            payload.put("apps", apps());
+            Api.json(response, payload);
+        } else if (path.matches("/api/apps/.+/sessions")) {
+            handleSessionsList(request, response, contextName(request));
+        } else if (path.matches("/api/apps/.+/sessions/[^/]+")) {
+            handleSessionDetail(request, response, contextName(request));
+        } else if (path.equals("/api/ssl/ciphers")) {
+            Api.json(response, getConnectorCiphers(legacySm(request)));
+        } else if (path.equals("/api/ssl/certs")) {
+            Api.json(response, getConnectorCerts(legacySm(request)));
+        } else if (path.equals("/api/ssl/trusted")) {
+            Api.json(response, getConnectorTrustedCerts(legacySm(request)));
+        } else if (path.equals("/api/leaks")) {
+            StringWriter writer = new StringWriter();
+            try (PrintWriter pw = new PrintWriter(writer)) {
+                super.findleaks(false, pw, legacySm(request));
+            }
+            List leaks = new ArrayList<>();
+            for (String line : writer.toString().split("\\R")) {
+                if (!line.isEmpty()) {
+                    leaks.add(line);
+                }
+            }
+            Map payload = new LinkedHashMap<>();
+            payload.put("leaks", leaks);
+            Api.json(response, payload);
+        } else if (path.equals("/api/resources")) {
+            StringWriter writer = new StringWriter();
+            try (PrintWriter pw = new PrintWriter(writer)) {
+                super.resources(pw, request.getParameter("type"), legacySm(request));
+            }
+            Map payload = new LinkedHashMap<>();
+            // The legacy resources() method writes a human readable status
+            // message as its first line (for example "OK - Listed global
+            // resources of all types") before the resource entries. That
+            // line is a presentation artifact of the classic manager's HTML
+            // page, not a resource, so it is dropped from the payload.
+            payload.put("resources", dropFirstLine(writer.toString()));
+            Api.json(response, payload);
+        } else if (path.equals("/api/diagnostics/vminfo")) {
+            StringWriter writer = new StringWriter();
+            try (PrintWriter pw = new PrintWriter(writer)) {
+                super.vmInfo(pw, legacySm(request), request.getLocales());
+            }
+            Map payload = new LinkedHashMap<>();
+            payload.put("info", writer.toString());
+            Api.json(response, payload);
+        } else if (path.equals("/api/diagnostics/threaddump")) {
+            StringWriter writer = new StringWriter();
+            try (PrintWriter pw = new PrintWriter(writer)) {
+                super.threadDump(pw, legacySm(request), request.getLocales());
+            }
+            Map payload = new LinkedHashMap<>();
+            payload.put("dump", writer.toString());
+            Api.json(response, payload);
+        } else {
+            Api.notFound(response);
+        }
+    }
+
+
+    @Override
+    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        try {
+            doPostInternal(request, response);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void doPostInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+        String path = path(request);
+
+        if (path.equals("/api/apps/deploy")) {
+            handleDeploy(request, response);
+        } else if (path.equals("/api/apps/upload")) {
+            handleUpload(request, response);
+        } else if (path.matches("/api/apps/.+/start")) {
+            handleLifecycle(request, response, contextName(request), "start");
+        } else if (path.matches("/api/apps/.+/stop")) {
+            handleLifecycle(request, response, contextName(request), "stop");
+        } else if (path.matches("/api/apps/.+/reload")) {
+            handleLifecycle(request, response, contextName(request), "reload");
+        } else if (path.matches("/api/apps/.+/expire")) {
+            handleExpire(request, response, contextName(request));
+        } else if (path.matches("/api/apps/.+/sessions/invalidate")) {
+            handleInvalidate(request, response, contextName(request));
+        } else if (path.equals("/api/ssl/reload")) {
+            handleSslReload(request, response);
+        } else {
+            Api.notFound(response);
+        }
+    }
+
+
+    @Override
+    public void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        try {
+            doDeleteInternal(request, response);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void doDeleteInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+        String path = path(request);
+
+        if (path.matches("/api/apps/.+/sessions/[^/]+/attributes/[^/]+")) {
+            handleRemoveAttribute(request, response, contextName(request));
+        } else if (path.matches("/api/apps/.+")) {
+            handleUndeploy(request, response, contextName(request));
+        } else {
+            Api.notFound(response);
+        }
+    }
+
+
+    // -------------------------------------------------------------- Actions
+
+
+    private List> apps() {
+        List> result = new ArrayList<>();
+        Container[] children = host.findChildren();
+        for (Container child : children) {
+            Context context = (Context) child;
+            Map app = new LinkedHashMap<>();
+            app.put("host", host.getName());
+            app.put("path", context.getPath());
+            app.put("displayName", context.getDisplayName());
+            app.put("version", context.getWebappVersion());
+            app.put("docBase", context.getDocBase());
+            app.put("available", Boolean.valueOf(context.getState().isAvailable()));
+            org.apache.catalina.Manager manager = context.getManager();
+            if (manager != null) {
+                app.put("sessions", Integer.valueOf(manager.getActiveSessions()));
+                app.put("sessionTimeout", Integer.valueOf(context.getSessionTimeout()));
+            } else {
+                app.put("sessions", 0);
+                app.put("sessionTimeout", null);
+            }
+            try {
+                app.put("deployed", Boolean.valueOf(isDeployed(context.getName())));
+            } catch (Exception e) {
+                app.put("deployed", Boolean.FALSE);
+            }
+            app.put("self", Boolean.valueOf(context.getName().equals(this.context.getName())));
+            result.add(app);
+        }
+        result.sort((a, b) -> String.valueOf(a.get("path")).compareTo(String.valueOf(b.get("path"))));
+        return result;
+    }
+
+
+    private void handleLifecycle(HttpServletRequest request, HttpServletResponse response, ContextName cn,
+            String action) throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            String message = invoke(pw -> {
+                switch (action) {
+                    case "start" -> super.start(pw, cn, legacySm(request));
+                    case "stop" -> super.stop(pw, cn, legacySm(request));
+                    case "reload" -> super.reload(pw, cn, legacySm(request));
+                    default -> {
+                    }
+                }
+            });
+            sendResult(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleUndeploy(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            String message = invoke(pw -> super.undeploy(pw, cn, legacySm(request)));
+            sendResult(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleExpire(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            Map body = readJson(request);
+            int idle = -1;
+            Object idleValue = body.get("idle");
+            if (idleValue instanceof Number n) {
+                idle = n.intValue();
+            }
+            int idleFinal = idle;
+            String message = invoke(pw -> super.sessions(pw, cn, idleFinal, legacySm(request)));
+            sendResult(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleDeploy(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        try {
+            Map body = readJson(request);
+            StringManager smClient = legacySm(request);
+
+            String config = string(body.get("config"));
+            String war = string(body.get("war"));
+            boolean replace = booleanValue(body.get("replace"), false);
+
+            ContextName cn;
+            String pathValue = string(body.get("path"));
+            if (pathValue != null && !pathValue.isEmpty()) {
+                cn = new ContextName(decode(pathValue), string(body.get("version")));
+            } else if (config != null && !config.isEmpty()) {
+                cn = ContextName.extractFromPath(config);
+            } else if (war != null && !war.isEmpty()) {
+                cn = ContextName.extractFromPath(war);
+            } else {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+
+            String message = invoke(pw -> super.deploy(pw, config, cn, war, replace, smClient));
+            sendResult(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage());
+        }
+    }
+
+
+    private void handleUpload(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        StringManager smClient = legacySm(request);
+        StringManager smLegacy = StringManager.getManager("org.apache.catalina.manager", request.getLocales());
+
+        try {
+            Part warPart = request.getPart("war");
+            if (warPart == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UPLOAD_NO_FILE",
+                        smLegacy.getString("htmlManagerServlet.deployUploadNoFile"));
+                return;
+            }
+            String filename = warPart.getSubmittedFileName();
+            if (filename == null || !filename.toLowerCase(Locale.ENGLISH).endsWith(".war")) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UPLOAD_NOT_WAR",
+                        smLegacy.getString("htmlManagerServlet.deployUploadNotWar", filename));
+                return;
+            }
+            int slash = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
+            if (slash >= 0) {
+                filename = filename.substring(slash + 1);
+            }
+
+            String pathParam = request.getParameter("path");
+            ContextName cn;
+            if (pathParam != null && !pathParam.isEmpty()) {
+                cn = new ContextName(decode(pathParam), request.getParameter("version"));
+            } else {
+                cn = new ContextName(filename, true);
+            }
+
+            StringWriter validation = new StringWriter();
+            try (PrintWriter pw = new PrintWriter(validation)) {
+                if (!validateContextName(cn, pw, smClient)) {
+                    Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                            validation.toString().trim());
+                    return;
+                }
+            }
+
+            String name = cn.getName();
+            boolean replace = "true".equals(request.getParameter("replace"));
+            Context existing = (Context) host.findChild(name);
+            if (existing != null && !replace) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "ALREADY_DEPLOYED",
+                        smLegacy.getString("managerServlet.alreadyContext", cn.getDisplayName()));
+                return;
+            }
+
+            File appBase = host.getAppBaseFile();
+            File deployedWar = new File(appBase, cn.getBaseName() + ".war");
+            if (!pathCheck(deployedWar, appBase, response)) {
+                return;
+            }
+            File target = replace ? new File(deployedWar.getAbsolutePath() + ".tmp") : deployedWar;
+            if (!replace && target.exists()) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "WAR_EXISTS",
+                        smLegacy.getString("htmlManagerServlet.deployUploadWarExists", filename));
+                return;
+            }
+
+            if (tryAddServiced(name)) {
+                try {
+                    warPart.write(target.getAbsolutePath());
+                    if (replace) {
+                        if (deployedWar.exists() && !deployedWar.delete()) {
+                            Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "DELETE_FAILED",
+                                    smLegacy.getString("managerServlet.deleteFail", deployedWar));
+                            return;
+                        }
+                        if (!target.renameTo(deployedWar)) {
+                            Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "RENAME_FAILED",
+                                    smLegacy.getString("managerServlet.renameFail", target, deployedWar));
+                            return;
+                        }
+                    }
+                } finally {
+                    removeServiced(name);
+                }
+                check(name);
+            } else {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "IN_SERVICE",
+                        smLegacy.getString("managerServlet.inService", cn.getDisplayName()));
+                return;
+            }
+
+            Context deployed = (Context) host.findChild(name);
+            String message;
+            if (deployed != null && deployed.getConfigured() && deployed.getState().isAvailable()) {
+                message = smLegacy.getString("managerServlet.deployed", cn.getDisplayName());
+            } else if (deployed != null && !deployed.getState().isAvailable()) {
+                message = smLegacy.getString("managerServlet.deployedButNotStarted", cn.getDisplayName());
+            } else {
+                message = smLegacy.getString("managerServlet.deployFailed", cn.getDisplayName());
+            }
+            Api.ok(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage());
+        } catch (Exception e) {
+            log(sm.getString("manager2.error.upload"), e);
+            Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "UPLOAD_FAILED",
+                    smLegacy.getString("htmlManagerServlet.deployUploadFail", e.getMessage()));
+        }
+    }
+
+
+    private void handleSessionsList(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            StringManager smClient = legacySm(request);
+            List sessions = getSessionsForName(cn, smClient);
+
+            String sortBy = request.getParameter("sort");
+            String orderBy = null;
+            if (sortBy != null && !sortBy.trim().isEmpty()) {
+                Comparator comparator = getComparator(sortBy);
+                if (comparator != null) {
+                    boolean asc = !"DESC".equalsIgnoreCase(request.getParameter("order"));
+                    if (!asc) {
+                        comparator = Collections.reverseOrder(comparator);
+                    }
+                    try {
+                        sessions.sort(comparator);
+                    } catch (IllegalStateException ise) {
+                        // At least one session was invalidated while sorting
+                    }
+                    orderBy = asc ? "ASC" : "DESC";
+                }
+            }
+
+            List> result = new ArrayList<>();
+            for (Session session : sessions) {
+                result.add(sessionToJson(session));
+            }
+            Map payload = new LinkedHashMap<>();
+            payload.put("path", cn.getPath());
+            payload.put("sort", sortBy);
+            payload.put("order", orderBy);
+            payload.put("sessions", result);
+            Api.json(response, payload);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleSessionDetail(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            String path = path(request);
+            int index = path.lastIndexOf("/sessions/");
+            if (index < 0) {
+                Api.notFound(response);
+                return;
+            }
+            String sessionId = path.substring(index + "/sessions/".length());
+
+            Session session = getSessionForNameAndId(cn, sessionId, legacySm(request));
+            if (session == null) {
+                Api.error(response, HttpServletResponse.SC_NOT_FOUND, "SESSION_NOT_FOUND",
+                        sm.getString("manager2.sessionNotFound", sessionId));
+                return;
+            }
+            Map payload = sessionToJson(session);
+            payload.put("attributes", attributesToJson(session));
+            Api.json(response, payload);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleInvalidate(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            Map body = readJson(request);
+            List ids = new ArrayList<>();
+            if (body.get("ids") instanceof List list) {
+                for (Object item : list) {
+                    ids.add(String.valueOf(item));
+                }
+            }
+            int count = invalidateSessions(cn, ids.toArray(new String[0]), legacySm(request));
+            Map payload = new LinkedHashMap<>();
+            payload.put("ok", Boolean.TRUE);
+            payload.put("count", Integer.valueOf(count));
+            payload.put("message", count + " sessions invalidated.");
+            Api.json(response, payload);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleRemoveAttribute(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        sm.getString("manager2.missingPath"));
+                return;
+            }
+            String path = path(request);
+            String attributesPrefix = "/attributes/";
+            int index = path.lastIndexOf(attributesPrefix);
+            if (index < 0) {
+                Api.notFound(response);
+                return;
+            }
+            String before = path.substring(0, index);
+            String sessionId = before.substring(before.lastIndexOf('/') + 1);
+            String attributeName = decode(path.substring(index + attributesPrefix.length()));
+
+            boolean removed = removeSessionAttribute(cn, sessionId, attributeName, legacySm(request));
+            if (removed) {
+                Api.ok(response, sm.getString("manager2.attributeRemoved", attributeName));
+            } else {
+                Api.error(response, HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND",
+                        sm.getString("manager2.attributeNotFound", attributeName));
+            }
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+        }
+    }
+
+
+    private void handleSslReload(HttpServletRequest request, HttpServletResponse response) throws IOException {
+        try {
+            Map body = readJson(request);
+            String tlsHostName = string(body.get("tlsHostName"));
+            String message = invoke(pw -> super.sslReload(pw, tlsHostName, legacySm(request)));
+            sendResult(response, message);
+        } catch (IllegalArgumentException e) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage());
+        }
+    }
+
+
+    // ------------------------------------------------------------- Helpers
+
+
+    private Map sessionToJson(Session session) {
+        Map result = new LinkedHashMap<>();
+        result.put("id", session.getId());
+        result.put("creationTime", Long.valueOf(session.getCreationTime()));
+        result.put("lastAccessedTime", Long.valueOf(session.getLastAccessedTime()));
+        result.put("maxInactiveInterval", Integer.valueOf(session.getMaxInactiveInterval()));
+        HttpSession httpSession = session.getSession();
+        result.put("active", Boolean.valueOf(httpSession != null));
+        result.put("isNew", Boolean.valueOf(httpSession != null && httpSession.isNew()));
+        result.put("locale", JspHelper.guessDisplayLocaleFromSession(session));
+        result.put("user", JspHelper.guessDisplayUserFromSession(session));
+        return result;
+    }
+
+
+    private List> attributesToJson(Session session) {
+        List> result = new ArrayList<>();
+        HttpSession httpSession = session.getSession();
+        if (httpSession == null) {
+            return result;
+        }
+        for (String name : Collections.list(httpSession.getAttributeNames())) {
+            Object value = httpSession.getAttribute(name);
+            Map attribute = new LinkedHashMap<>();
+            attribute.put("name", name);
+            attribute.put("class", value != null ? value.getClass().getName() : null);
+            String text = value == null ? null : String.valueOf(value);
+            if (text != null && text.length() > 500) {
+                text = text.substring(0, 500) + "...";
+            }
+            attribute.put("value", text);
+            result.add(attribute);
+        }
+        result.sort((a, b) -> String.valueOf(a.get("name")).compareTo(String.valueOf(b.get("name"))));
+        return result;
+    }
+
+
+    private static String path(HttpServletRequest request) {
+        String path = request.getServletPath();
+        String info = request.getPathInfo();
+        if (info != null && !info.isEmpty()) {
+            path = path + info;
+        }
+        return path;
+    }
+
+
+    /**
+     * Decode the context from the request. The context path is taken from the {@code path} query parameter when present
+     * (which also supports context paths containing slashes), otherwise from the first path segment after
+     * {@code /api/apps}. Returns {@code null} when no path is present.
+     */
+    private ContextName contextName(HttpServletRequest request) {
+        String version = request.getParameter("version");
+        // The client always sends the (possibly empty) context path as the
+        // "path" query parameter. Note that getParameter() returns "" when
+        // the parameter is present with an empty value (ROOT context) and
+        // null when it is absent.
+        String pathParam = request.getParameter("path");
+        if (pathParam != null) {
+            return new ContextName(decode(pathParam), version);
+        }
+        String path = path(request);
+        String prefix = "/api/apps/";
+        if (!path.startsWith(prefix)) {
+            return null;
+        }
+        String rest = path.substring(prefix.length());
+        int slash = rest.indexOf('/');
+        String encoded = slash >= 0 ? rest.substring(0, slash) : rest;
+        if (encoded.isEmpty()) {
+            return null;
+        }
+        String decoded = decode(encoded);
+        if (decoded.equals("root")) {
+            // URL segment convention for the ROOT context
+            decoded = "";
+        }
+        return new ContextName(decoded, version);
+    }
+
+
+    private static String decode(String value) {
+        return URLDecoder.decode(value, StandardCharsets.UTF_8);
+    }
+
+
+    /**
+     * Everything after the first line of the given text (or an empty string when the text has at most one line).
+     */
+    private static String dropFirstLine(String text) {
+        int index = text.indexOf('\n');
+        return index < 0 ? "" : text.substring(index + 1);
+    }
+
+
+    private static String string(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+
+    private static boolean booleanValue(Object value, boolean defaultValue) {
+        if (value instanceof Boolean b) {
+            return b;
+        }
+        if (value instanceof String s) {
+            return Boolean.parseBoolean(s);
+        }
+        return defaultValue;
+    }
+
+
+    private static Map readJson(HttpServletRequest request) throws IOException {
+        String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+        if (body.isBlank()) {
+            return new LinkedHashMap<>();
+        }
+        try {
+            return new JSONParser(body).parseObject();
+        } catch (Exception e) {
+            throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e);
+        }
+    }
+
+
+    private String invoke(Operation operation) {
+        StringWriter stringWriter = new StringWriter();
+        try (PrintWriter writer = new PrintWriter(stringWriter)) {
+            operation.run(writer);
+        }
+        return stringWriter.toString().trim();
+    }
+
+
+    // Keep it since it can be useful eventually
+    @SuppressWarnings("unused")
+    private static StringManager clientSm(HttpServletRequest request) {
+        return StringManager.getManager(Constants.Package, request.getLocales());
+    }
+
+
+    /**
+     * A StringManager for the legacy manager message bundle. All the inherited operations report their results using
+     * the {@code org.apache.catalina.manager} strings, so that bundle has to be used for localized output.
+     */
+    private static StringManager legacySm(HttpServletRequest request) {
+        return StringManager.getManager("org.apache.catalina.manager", request.getLocales());
+    }
+
+
+    /**
+     * Ensure the file is contained in the expected directory (canonical path containment, same check as the legacy
+     * manager servlets).
+     */
+    private static boolean pathCheck(File input, File expected, HttpServletResponse response) throws IOException {
+        try {
+            if (!input.getCanonicalFile().toPath().startsWith(expected.getCanonicalFile().toPath())) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "PATH_CHECK_FAILED",
+                        sm.getString("manager2.pathCheckFail", input, expected));
+                return false;
+            }
+        } catch (IOException ioe) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "PATH_CHECK_ERROR",
+                    sm.getString("manager2.pathCheckError", input, expected, ioe.getMessage()));
+            return false;
+        }
+        return true;
+    }
+
+
+    private void sendResult(HttpServletResponse response, String message) throws IOException {
+        if (message.startsWith("FAIL -")) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "OPERATION_FAILED", message);
+        } else {
+            Api.ok(response, message);
+        }
+    }
+
+
+    @FunctionalInterface
+    private interface Operation {
+        void run(PrintWriter writer);
+    }
+}
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java
new file mode 100644
index 000000000000..29cdae3c80da
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java
@@ -0,0 +1,5109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  See the License for the specific language governing
+ *  permissions and limitations under the License.
+ *
+ *      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.
+ */
+package org.apache.tomcat.manager2;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.Serial;
+import java.io.StringWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.apache.catalina.Cluster;
+import org.apache.catalina.Container;
+import org.apache.catalina.ContainerServlet;
+import org.apache.catalina.Context;
+import org.apache.catalina.Engine;
+import org.apache.catalina.Executor;
+import org.apache.catalina.Host;
+import org.apache.catalina.Lifecycle;
+import org.apache.catalina.LifecycleListener;
+import org.apache.catalina.Loader;
+import org.apache.catalina.Manager;
+import org.apache.catalina.Pipeline;
+import org.apache.catalina.Realm;
+import org.apache.catalina.Server;
+import org.apache.catalina.Service;
+import org.apache.catalina.SessionIdGenerator;
+import org.apache.catalina.Valve;
+import org.apache.catalina.WebResourceRoot;
+import org.apache.catalina.Wrapper;
+import org.apache.catalina.connector.Connector;
+import org.apache.catalina.core.StandardContext;
+import org.apache.catalina.core.StandardEngine;
+import org.apache.catalina.core.StandardHost;
+import org.apache.catalina.core.StandardServer;
+import org.apache.catalina.core.StandardService;
+import org.apache.catalina.core.StandardThreadExecutor;
+import org.apache.catalina.core.StandardWrapper;
+import org.apache.catalina.deploy.NamingResourcesImpl;
+import org.apache.catalina.ha.CatalinaCluster;
+import org.apache.catalina.ha.ClusterDeployer;
+import org.apache.catalina.ha.ClusterListener;
+import org.apache.catalina.ha.ClusterManager;
+import org.apache.catalina.ha.ClusterValve;
+import org.apache.catalina.ha.tcp.SimpleTcpCluster;
+import org.apache.catalina.loader.WebappLoader;
+import org.apache.catalina.realm.CombinedRealm;
+import org.apache.catalina.realm.MemoryRealm;
+import org.apache.catalina.realm.RealmBase;
+import org.apache.catalina.session.ManagerBase;
+import org.apache.catalina.startup.Bootstrap;
+import org.apache.catalina.startup.ContextConfig;
+import org.apache.catalina.startup.HostConfig;
+import org.apache.catalina.storeconfig.IStoreFactory;
+import org.apache.catalina.storeconfig.StandardContextSF;
+import org.apache.catalina.storeconfig.StoreConfig;
+import org.apache.catalina.storeconfig.StoreDescription;
+import org.apache.catalina.storeconfig.StoreFileMover;
+import org.apache.catalina.storeconfig.StoreLoader;
+import org.apache.catalina.tribes.Channel;
+import org.apache.catalina.tribes.ChannelInterceptor;
+import org.apache.catalina.tribes.ChannelReceiver;
+import org.apache.catalina.tribes.ChannelSender;
+import org.apache.catalina.tribes.ManagedChannel;
+import org.apache.catalina.tribes.Member;
+import org.apache.catalina.tribes.MembershipService;
+import org.apache.catalina.tribes.group.GroupChannel;
+import org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor;
+import org.apache.catalina.tribes.group.interceptors.StaticMembershipInterceptor;
+import org.apache.catalina.tribes.group.interceptors.TcpFailureDetector;
+import org.apache.catalina.tribes.membership.McastService;
+import org.apache.catalina.tribes.membership.StaticMember;
+import org.apache.catalina.tribes.membership.StaticMembershipService;
+import org.apache.catalina.tribes.transport.AbstractSender;
+import org.apache.catalina.tribes.transport.MultiPointSender;
+import org.apache.catalina.tribes.transport.ReceiverBase;
+import org.apache.catalina.tribes.transport.ReplicationTransmitter;
+import org.apache.catalina.util.LifecycleBase;
+import org.apache.catalina.util.SessionIdGeneratorBase;
+import org.apache.coyote.AbstractProtocol;
+import org.apache.coyote.http11.AbstractHttp11Protocol;
+import org.apache.tomcat.util.descriptor.web.ContextEjb;
+import org.apache.tomcat.util.descriptor.web.ContextEnvironment;
+import org.apache.tomcat.util.descriptor.web.ContextLocalEjb;
+import org.apache.tomcat.util.descriptor.web.ContextResource;
+import org.apache.tomcat.util.descriptor.web.ContextResourceEnvRef;
+import org.apache.tomcat.util.descriptor.web.ContextResourceLink;
+import org.apache.tomcat.util.descriptor.web.ContextService;
+import org.apache.tomcat.util.descriptor.web.ResourceBase;
+import org.apache.tomcat.util.http.CookieProcessor;
+import org.apache.tomcat.util.http.CookieProcessorBase;
+import org.apache.tomcat.util.json.JSONParser;
+import org.apache.tomcat.util.modeler.AttributeInfo;
+import org.apache.tomcat.util.modeler.ManagedBean;
+import org.apache.tomcat.util.modeler.Registry;
+import org.apache.tomcat.util.net.AbstractEndpoint;
+import org.apache.tomcat.util.net.SSLHostConfig;
+import org.apache.tomcat.util.net.SSLHostConfigCertificate;
+import org.apache.tomcat.util.res.StringManager;
+
+
+/**
+ * The Manager2 configuration API. Exposes the complete component tree of the Catalina {@code Server} (services,
+ * engines, hosts, contexts, wrappers, valves, connectors, executors, listeners, host aliases, realms, TLS host
+ * configurations, the context sub components manager, session id generator, resources, loader and cookie processor, and
+ * the JNDI naming resources of the server and of the contexts), allows reading and updating the descriptor defined
+ * attributes of any component, adding and removing child components, and persisting the live state to
+ * {@code conf/server.xml} through the storeconfig mechanism.
+ * 

+ * Changes are applied to the running server immediately. Persisting (store) rewrites {@code conf/server.xml} from the + * live state and keeps a timestamped backup of the previous file. + *

+ * Contexts keep their storage location when the configuration is persisted, mirroring regular StoreConfig + * behavior: a context that is backed by its own configuration file (its {@code META-INF/context.xml} or + * {@code conf/Catalina/.../context.xml}) is written back to that file, and a context defined inline in + * {@code server.xml} stays inline. A context restarts when its own file is (re)written, so a save that rewrites the + * file of this web application restarts the manager and resets the admin session; the UI warns about this before the + * save. The store preview is read-only: it reports the resulting {@code server.xml} and the external context files that + * would be rewritten, without touching the configuration on disk. + *

+ * Node addressing. Every node of the tree carries a path based {@code id} made of named segments (URL encoded, + * where a {@code /} inside a value - e.g. in a context path - is written as {@code +}) and, for components that have no + * name (valves, connectors, listeners), a positional index: + * + *

+ *   server
+ *   server/service/{name}
+ *   server/service/{s}/engine/{name}
+ *   server/service/{s}/engine/{e}/host/{name}
+ *   server/service/{s}/engine/{e}/host/{h}/alias/{alias}
+ *   server/service/{s}/engine/{e}/host/{h}/context/{path}
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/wrapper/{name}
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/valve/{index}
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/manager/0
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/manager/0/sessionIdGenerator/0
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/resources/0
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/loader/0
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/cookieProcessor/0
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/namingResources/0
+ *   server/namingResources/0
+ *   server/namingResources/0/resource/{name}
+ *   server/namingResources/0/resourceLink/{name}
+ *   server/namingResources/0/resourceEnvRef/{name}
+ *   server/namingResources/0/environment/{name}
+ *   server/namingResources/0/ejb/{name}
+ *   server/namingResources/0/localEjb/{name}
+ *   server/namingResources/0/serviceRef/{name}
+ *   server/service/{s}/engine/{e}/host/{h}/context/{p}/namingResources/0/resource/{name}
+ *   ...
+ *   server/service/{s}/engine/{e}/host/{h}/valve/{index}
+ *   server/service/{s}/engine/{e}/valve/{index}
+ *   server/service/{s}/engine/{e}/listener/{index}
+ *   server/service/{s}/connector/{index}
+ *   server/service/{s}/connector/{i}/sslHostConfig/{hostName}
+ *   server/service/{s}/connector/{i}/sslHostConfig/{h}/certificate/{index}
+ *   server/service/{s}/executor/{name}
+ *   server/service/{s}/valve/{index}
+ *   server/service/{s}/listener/{index}
+ *   server/valve/{index}
+ *   server/listener/{index}
+ * 
+ * + * The tree endpoint generates these ids; clients only echo them back. + *

+ * Attributes. The property list of a node is derived from the modeler MBean descriptor of the component's class + * (the same contract that defines the {@code Catalina:*} MBeans). The TLS components ({@code SSLHostConfig} and + * {@code SSLHostConfigCertificate}), the context sub components ({@code WebappLoader}, {@code CookieProcessorBase} + * subclasses and {@code SessionIdGeneratorBase} subclasses) and the JNDI entry nodes have no (complete) modeler + * descriptor; their editable attribute list is defined explicitly by this servlet. Only attributes that map to a simple + * UI type (boolean, integral, string, string array) are editable; everything else is reported read-only. + *

+ * Context sub components. A running context always has exactly one manager, one resource root, one loader and + * one cookie processor (the defaults are created at context start). Adding one of these to a context therefore replaces + * the current instance with a new instance of the given class; the current instance is stopped first (where it has a + * lifecycle). These components are required by the context and cannot be removed - only replaced. Replacing the + * resources of a running context is refused (the context must be stopped first). The manager's session id generator is + * replaced the same way. + *

+ * TLS. Adding an {@code sslHostConfig} to a connector enables TLS on that connector. The TLS state of a running + * connector only takes effect when the connector is restarted, so the operations that change it (add or remove an + * {@code sslHostConfig}, add or remove a {@code certificate}) restart the affected connector and roll back the change + * if the restart fails (for example because the keystore does not exist or the password is wrong). The connector that + * hosts this web application itself is never touched. + *

+ * JNDI naming resources. The server and every context have a {@code namingResources} node (a + * {@code NamingResourcesImpl}) that holds the JNDI entries: {@code resource}, {@code resourceLink}, + * {@code resourceEnvRef}, {@code environment}, {@code ejb}, {@code localEjb} and {@code service}. A server level node + * does not accept {@code resourceLink} entries (they only exist in the context JNDI environment and are not parsed from + * {@code }). Adding and removing an entry updates the live JNDI environment of the server (the + * global context) or of the context ({@code java:comp/env}) immediately; the container's {@code NamingContextListener} + * performs the JNDI bind/unbind on the property change event. Updating an attribute of an existing entry does not fire + * such an event, so the update is implemented as a remove plus re-add of the entry (the change is rolled back if the + * re-add fails). The factory specific options of a {@code resource} (e.g. {@code url}, {@code driverClassName}, + * {@code maxTotal}) are free form string parameters; for the first party JNDI {@code ObjectFactory} implementations + * shipped with Tomcat ({@code BasicDataSourceFactory}, {@code MemoryUserDatabaseFactory}, + * {@code DataSourceUserDatabaseFactory} and the per user / shared pool data source factories) the parameters they + * actually consume are listed in the node's property set, with validation. + *

+ * Like the rest of the mutable API, this API requires the {@code manager-gui} role and, for the mutating methods, a + * valid CSRF token. + */ +public class ConfigApiServlet extends HttpServlet implements ContainerServlet { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + + /** + * A component name that may be used for new hosts, wrappers, executors and services (and that is accepted for + * engines). + */ + private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+"); + + /** + * The attribute types that can be edited through the API. + */ + private static final Set EDITABLE_TYPES = Set.of("boolean", "int", "long", "short", "byte", "float", + "double", "java.lang.String", "[Ljava.lang.String;"); + + /** + * Attribute names whose update requires a type to confirm (renames and the like). + */ + private static final Set RISKY_ATTRIBUTES = Set.of("name", "path", "defaultHost"); + + /** + * The cluster sub component types. They are excluded from the generic "accepts a lifecycle listener" flag: only the + * cluster itself (not its channel, manager, ...) accepts a {@code } in the cluster model, even when the + * sub component is itself a lifecycle. + */ + private static final Set CLUSTER_SUB_TYPES = Set.of("channel", "membership", "sender", "receiver", + "transport", "interceptor", "deployer", "clusterManager", "clusterValve", "clusterListener", "member"); + + /** + * The maximum number of nested Realm levels, the same bound the XML parser applies to Realm elements + * (org.apache.catalina.startup.RealmRuleSet). + */ + private static final int MAX_NESTED_REALM_LEVELS = 3; + + + private transient Wrapper wrapper = null; + + private transient Context selfContext = null; + + private transient Host selfHost = null; + + private transient Server server = null; + + + // ------------------------------------------------ ContainerServlet API + + + @Override + public Wrapper getWrapper() { + return wrapper; + } + + + @Override + public void setWrapper(Wrapper wrapper) { + this.wrapper = wrapper; + if (wrapper == null) { + selfContext = null; + selfHost = null; + server = null; + } else { + selfContext = (Context) wrapper.getParent(); + selfHost = (Host) selfContext.getParent(); + Engine engine = (Engine) selfHost.getParent(); + Service service = engine.getService(); + server = (service != null) ? service.getServer() : null; + } + } + + + // ------------------------------------------------------------ Request API + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = path(request); + + try { + requireServer(); + if ("/api/config/tree".equals(path)) { + Map payload = new LinkedHashMap<>(); + payload.put("tree", build(server, "server", "server")); + Api.json(response, payload); + } else if (path.startsWith("/api/config/node/")) { + NodeRef ref = resolve(path.substring("/api/config/node/".length())); + Api.json(response, node(ref)); + } else if ("/api/config/store/preview".equals(path)) { + preview(response); + } else { + Api.notFound(response); + } + } catch (ConfigException e) { + Api.error(response, e.status, e.code, e.getMessage()); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ServletException(e); + } + } + + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = path(request); + Map body = readJson(request); + + try { + requireServer(); + if ("/api/config/attribute".equals(path)) { + updateAttribute(response, body); + } else if ("/api/config/child".equals(path)) { + addChild(response, body); + } else if ("/api/config/store".equals(path)) { + store(response); + } else { + Api.notFound(response); + } + } catch (ConfigException e) { + Api.error(response, e.status, e.code, e.getMessage()); + } catch (IllegalArgumentException e) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ServletException(e); + } + } + + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = path(request); + + try { + requireServer(); + if ("/api/config/child".equals(path)) { + Map body = readJson(request); + removeChild(response, body); + } else { + Api.notFound(response); + } + } catch (ConfigException e) { + Api.error(response, e.status, e.code, e.getMessage()); + } catch (IllegalArgumentException e) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ServletException(e); + } + } + + + // ------------------------------------------------------- Tree building + + + /** + * Build the tree entry of one component, including its children. + */ + private Map build(Object component, String id, String type) { + + Map node = new LinkedHashMap<>(); + node.put("id", id); + node.put("type", type); + node.put("className", component.getClass().getName()); + node.put("name", displayName(component, type)); + if (component instanceof Lifecycle lifecycle) { + node.put("state", lifecycle.getState().toString()); + } + if ("context".equals(type) && component == selfContext) { + node.put("self", Boolean.TRUE); + } + if ("namingResources".equals(type) && component instanceof NamingResourcesImpl namingResources) { + // The server level node (container is the Server) does not + // accept resourceLink entries; the client filters them out. + node.put("global", Boolean.valueOf(namingResources.getContainer() instanceof Server)); + } + + List> children = new ArrayList<>(); + if (component instanceof StandardServer s) { + children.add(build(s.getGlobalNamingResources(), id + "/namingResources/0", "namingResources")); + for (Service service : s.findServices()) { + children.add(build(service, id + "/service/" + enc(service.getName()), "service")); + } + children.addAll(childrenOfListeners(s, id)); + } else if (component instanceof StandardService service) { + Engine engine = service.getContainer(); + if (engine != null) { + children.add(build(engine, id + "/engine/" + enc(engine.getName()), "engine")); + } + children.addAll(childrenOfConnectors(service, id)); + children.addAll(childrenOfExecutors(service, id)); + children.addAll(childrenOfListeners(service, id)); + } else if (component instanceof StandardEngine engine) { + for (Container host : engine.findChildren()) { + if (host instanceof Host) { + children.add(build(host, id + "/host/" + enc(host.getName()), "host")); + } + } + children.addAll(childrenOfRealm(engine, id)); + children.addAll(childrenOfCluster(engine, id)); + children.addAll(childrenOfValves(engine, id)); + children.addAll(childrenOfListeners(engine, id)); + } else if (component instanceof Host host) { + for (String alias : host.findAliases()) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id + "/alias/" + enc(alias)); + entry.put("type", "alias"); + entry.put("className", String.class.getName()); + entry.put("name", alias); + entry.put("children", new ArrayList>()); + children.add(entry); + } + for (Container child : host.findChildren()) { + if (child instanceof Context context) { + children.add(build(context, id + "/context/" + encContextPath(context.getPath()), "context")); + } + } + children.addAll(childrenOfRealm(host, id)); + children.addAll(childrenOfCluster(host, id)); + children.addAll(childrenOfValves(host, id)); + children.addAll(childrenOfListeners((LifecycleBase) host, id)); + } else if (component instanceof Context context) { + for (Container child : context.findChildren()) { + if (child instanceof Wrapper wrapper) { + children.add(build(wrapper, id + "/wrapper/" + enc(wrapper.getName()), "wrapper")); + } + } + children.addAll(childrenOfRealm(context, id)); + children.addAll(childrenOfCluster(context, id)); + children.addAll(childrenOfContextComponents(context, id)); + children.add(build(context.getNamingResources(), id + "/namingResources/0", "namingResources")); + children.addAll(childrenOfValves(context, id)); + children.addAll(childrenOfListeners((LifecycleBase) context, id)); + } else if (component instanceof Connector connector) { + children.addAll(sslHostConfigChildren(connector, id)); + } else if (component instanceof SSLHostConfig sslHostConfig) { + children.addAll(certificateChildren(sslHostConfig, id)); + } else if (component instanceof CatalinaCluster cluster) { + children.addAll(clusterChildren(cluster, id)); + } else if (component instanceof Channel channel) { + if (channel instanceof ManagedChannel managed) { + children.addAll(channelChildren(managed, id)); + } + } else if (component instanceof ChannelSender sender) { + children.addAll(senderChildren(sender, id)); + } else if (component instanceof MembershipService membership) { + children.addAll(membershipChildren(membership, id)); + } else if (component instanceof StaticMembershipInterceptor interceptor) { + children.addAll(interceptorChildren(interceptor, id)); + } else if (component instanceof Realm realm) { + children.addAll(childrenOfSubRealms(realm, id)); + } else if (component instanceof Manager manager) { + children.addAll(sessionIdGeneratorChildren(manager, id)); + } else if (component instanceof NamingResourcesImpl namingResources) { + children.addAll(childrenOfNamingResources(namingResources, id)); + } + node.put("children", children); + return node; + } + + + /** + * The tree entries of the TLS (SSL) host configurations of a connector, each with its certificate children. + */ + private List> sslHostConfigChildren(Connector connector, String parentId) { + List> result = new ArrayList<>(); + SSLHostConfig[] hostConfigs = connector.findSslHostConfigs(); + if (hostConfigs == null || hostConfigs.length == 0) { + return result; + } + // The configurations are held in a hash map; sort them by host + // name so that the tree (and the order in which they are + // displayed) is stable across requests and restarts. + SSLHostConfig[] sorted = hostConfigs.clone(); + Arrays.sort(sorted, (a, b) -> a.getHostName().compareTo(b.getHostName())); + for (SSLHostConfig hostConfig : sorted) { + Map entry = new LinkedHashMap<>(); + String id = parentId + "/sslHostConfig/" + enc(hostConfig.getHostName()); + entry.put("id", id); + entry.put("type", "sslHostConfig"); + entry.put("className", hostConfig.getClass().getName()); + entry.put("name", hostConfig.getHostName()); + entry.put("children", certificateChildren(hostConfig, id)); + result.add(entry); + } + return result; + } + + + /** + * The tree entries of the certificates of an SSL host configuration. + */ + private List> certificateChildren(SSLHostConfig hostConfig, String parentId) { + List> result = new ArrayList<>(); + SSLHostConfigCertificate[] certificates = hostConfig.getCertificates().toArray(new SSLHostConfigCertificate[0]); + for (int i = 0; i < certificates.length; i++) { + SSLHostConfigCertificate certificate = certificates[i]; + Map entry = new LinkedHashMap<>(); + entry.put("id", parentId + "/certificate/" + i); + entry.put("type", "certificate"); + entry.put("className", certificate.getClass().getName()); + entry.put("name", certificateLabel(certificate)); + entry.put("children", new ArrayList>()); + result.add(entry); + } + return result; + } + + + /** + * A human readable name for a certificate configuration: the certificate type, with the (typeless) default + * certificate shown as "default". + */ + private static String certificateLabel(SSLHostConfigCertificate certificate) { + if (certificate.getType() == SSLHostConfigCertificate.Type.UNDEFINED) { + return "default"; + } + return certificate.getType().name(); + } + + + private List> childrenOfConnectors(StandardService service, String parentId) { + List> result = new ArrayList<>(); + Connector[] connectors = service.findConnectors(); + for (int i = 0; i < connectors.length; i++) { + Connector connector = connectors[i]; + String id = parentId + "/connector/" + i; + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + entry.put("type", "connector"); + entry.put("className", connector.getClass().getName()); + entry.put("name", connectorLabel(connector)); + entry.put("state", connector.getState().toString()); + entry.put("children", sslHostConfigChildren(connector, id)); + result.add(entry); + } + return result; + } + + + /** + * A human readable name for a connector: the configured protocol when known, otherwise the simple name of the + * protocol handler class. + */ + private static String connectorLabel(Connector connector) { + String protocol = connector.getProtocol(); + if (protocol == null || protocol.isEmpty()) { + String handler = connector.getProtocolHandlerClassName(); + int separator = handler != null ? handler.lastIndexOf('.') : -1; + protocol = (separator >= 0) ? handler.substring(separator + 1) : "connector"; + } + return protocol + " (port " + connector.getPort() + ")"; + } + + + private List> childrenOfExecutors(StandardService service, String parentId) { + List> result = new ArrayList<>(); + for (Executor executor : service.findExecutors()) { + Map entry = new LinkedHashMap<>(); + entry.put("id", parentId + "/executor/" + enc(executor.getName())); + entry.put("type", "executor"); + entry.put("className", executor.getClass().getName()); + entry.put("name", executor.getName()); + if (executor instanceof Lifecycle lifecycle) { + entry.put("state", lifecycle.getState().toString()); + } + entry.put("children", new ArrayList>()); + result.add(entry); + } + return result; + } + + + private List> childrenOfValves(Container container, String parentId) { + List> result = new ArrayList<>(); + Valve[] valves = container.getPipeline().getValves(); + for (int i = 0; i < valves.length; i++) { + Valve valve = valves[i]; + Map entry = new LinkedHashMap<>(); + entry.put("id", parentId + "/valve/" + i); + entry.put("type", "valve"); + entry.put("className", valve.getClass().getName()); + entry.put("name", valve.getClass().getSimpleName()); + entry.put("basic", Boolean.valueOf(i == 0)); + entry.put("children", new ArrayList>()); + result.add(entry); + } + return result; + } + + + private List> childrenOfListeners(LifecycleBase component, String parentId) { + List> result = new ArrayList<>(); + LifecycleListener[] listeners = component.findLifecycleListeners(); + for (int i = 0; i < listeners.length; i++) { + LifecycleListener listener = listeners[i]; + Map entry = new LinkedHashMap<>(); + entry.put("id", parentId + "/listener/" + i); + entry.put("type", "listener"); + entry.put("className", listener.getClass().getName()); + entry.put("name", listener.getClass().getSimpleName()); + entry.put("children", new ArrayList>()); + result.add(entry); + } + return result; + } + + + /** + * The realm directly attached to the container, or {@code null} when the container has none of its own. + * {@code getRealm()} falls back to the parent container's realm, so the fallback (the same comparison storeconfig + * uses) must be excluded. + */ + private static Realm ownRealm(Container container) { + Realm realm = container.getRealm(); + if (realm == null) { + return null; + } + Container parent = container.getParent(); + if (parent != null && realm == parent.getRealm()) { + return null; + } + return realm; + } + + + /** + * The realm the container resolves to (via {@code getRealm()}) once its own realm has been detached. + */ + private static Realm fallbackRealm(Container container) { + Container parent = container.getParent(); + return parent != null ? parent.getRealm() : null; + } + + + /** + * The tree entry for the realm directly attached to the container, if it has one. + */ + private List> childrenOfRealm(Container container, String parentId) { + List> result = new ArrayList<>(); + Realm realm = ownRealm(container); + if (realm != null) { + result.add(realmEntry(realm, parentId + "/realm/0")); + } + return result; + } + + + /** + * The tree entries of the (sub) realms nested in the given realm. Only combined realms hold sub realms. + */ + private List> childrenOfSubRealms(Realm realm, String parentId) { + List> result = new ArrayList<>(); + if (!(realm instanceof CombinedRealm combined)) { + return result; + } + Realm[] nested = combined.getNestedRealms(); + for (int i = 0; i < nested.length; i++) { + result.add(realmEntry(nested[i], parentId + "/realm/" + i)); + } + return result; + } + + + private Map realmEntry(Realm realm, String id) { + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + entry.put("type", "realm"); + entry.put("className", realm.getClass().getName()); + entry.put("name", realm.getClass().getSimpleName()); + if (realm instanceof Lifecycle lifecycle) { + entry.put("state", lifecycle.getState().toString()); + } + entry.put("children", childrenOfSubRealms(realm, id)); + return entry; + } + + + /** + * The tree entries of the sub components of a context (manager, resources, loader, cookie processor). A running + * context always has all of them (the defaults are created at context start), so they are always shown once the + * context has started. + */ + private List> childrenOfContextComponents(Context context, String parentId) { + List> result = new ArrayList<>(); + Manager manager = context.getManager(); + if (manager != null) { + result.add(build(manager, parentId + "/manager/0", "manager")); + } + WebResourceRoot resources = context.getResources(); + if (resources != null) { + result.add(build(resources, parentId + "/resources/0", "resources")); + } + Loader loader = context.getLoader(); + if (loader != null) { + result.add(build(loader, parentId + "/loader/0", "loader")); + } + CookieProcessor cookieProcessor = context.getCookieProcessor(); + if (cookieProcessor != null) { + result.add(build(cookieProcessor, parentId + "/cookieProcessor/0", "cookieProcessor")); + } + return result; + } + + + /** + * The tree entry of the session id generator of a manager, if it has one (a running manager always does; the + * manager creates a default one at start). + */ + private List> sessionIdGeneratorChildren(Manager manager, String parentId) { + List> result = new ArrayList<>(); + SessionIdGenerator generator = manager.getSessionIdGenerator(); + if (generator != null) { + result.add(build(generator, parentId + "/sessionIdGenerator/0", "sessionIdGenerator")); + } + return result; + } + + + /** + * The tree entries of the JNDI entries of a {@code NamingResourcesImpl}, keyed by their JNDI names. A server level + * instance (container is the {@code Server}) does not expose resource links: they are only part of a context JNDI + * environment and are not parsed from {@code }. + */ + private List> childrenOfNamingResources(NamingResourcesImpl namingResources, String parentId) { + List> result = new ArrayList<>(); + boolean global = namingResources.getContainer() instanceof Server; + for (ContextResource resource : namingResources.findResources()) { + result.add(namingEntry(resource, "resource", parentId)); + } + if (!global) { + for (ContextResourceLink link : namingResources.findResourceLinks()) { + result.add(namingEntry(link, "resourceLink", parentId)); + } + } + for (ContextResourceEnvRef resourceEnvRef : namingResources.findResourceEnvRefs()) { + result.add(namingEntry(resourceEnvRef, "resourceEnvRef", parentId)); + } + for (ContextEnvironment environment : namingResources.findEnvironments()) { + result.add(namingEntry(environment, "environment", parentId)); + } + for (ContextEjb ejb : namingResources.findEjbs()) { + result.add(namingEntry(ejb, "ejb", parentId)); + } + for (ContextLocalEjb localEjb : namingResources.findLocalEjbs()) { + result.add(namingEntry(localEjb, "localEjb", parentId)); + } + for (ContextService service : namingResources.findServices()) { + result.add(namingEntry(service, "serviceRef", parentId)); + } + return result; + } + + + private Map namingEntry(ResourceBase entry, String type, String parentId) { + Map node = new LinkedHashMap<>(); + node.put("id", parentId + "/" + type + "/" + enc(entry.getName())); + node.put("type", type); + node.put("className", entry.getClass().getName()); + node.put("name", entry.getName()); + node.put("children", new ArrayList>()); + return node; + } + + + // ------------------------------------------------------ Cluster children + + + /** + * The cluster directly attached to the container, or {@code null} when the container has none of its own. + * {@code getCluster()} falls back to the parent container's cluster, so the fallback (the same comparison + * storeconfig uses) must be excluded. + */ + private static Cluster ownCluster(Container container) { + Cluster cluster = container.getCluster(); + if (cluster == null) { + return null; + } + Container parent = container.getParent(); + if (parent != null && cluster == parent.getCluster()) { + return null; + } + return cluster; + } + + + /** + * The tree entry for the cluster directly attached to the container, if it has one. + */ + private List> childrenOfCluster(Container container, String parentId) { + List> result = new ArrayList<>(); + Cluster cluster = ownCluster(container); + if (cluster != null) { + result.add(build(cluster, parentId + "/cluster/0", "cluster")); + } + return result; + } + + + /** + * The tree children of a cluster: channel, deployer, valves, manager template (SimpleTcpCluster), lifecycle + * listeners and cluster listeners. + */ + private List> clusterChildren(CatalinaCluster cluster, String parentId) { + List> result = new ArrayList<>(); + Channel channel = cluster.getChannel(); + if (channel != null) { + result.add(build(channel, parentId + "/channel/0", "channel")); + } + ClusterDeployer deployer = cluster.getClusterDeployer(); + if (deployer != null) { + result.add(build(deployer, parentId + "/deployer/0", "deployer")); + } + Valve[] valves = cluster.getValves(); + for (int i = 0; i < valves.length; i++) { + result.add(build(valves[i], parentId + "/clusterValve/" + i, "clusterValve")); + } + if (cluster instanceof SimpleTcpCluster tcp) { + ClusterManager manager = tcp.getManagerTemplate(); + if (manager != null) { + result.add(build(manager, parentId + "/clusterManager/0", "clusterManager")); + } + if (cluster instanceof LifecycleBase base) { + LifecycleListener[] listeners = base.findLifecycleListeners(); + for (int i = 0; i < listeners.length; i++) { + result.add(build(listeners[i], parentId + "/listener/" + i, "listener")); + } + } + int idx = 0; + for (ClusterListener listener : tcp.findClusterListeners()) { + if (listener == deployer) { + // The deployer is already shown as its own child. + continue; + } + result.add(build(listener, parentId + "/clusterListener/" + idx, "clusterListener")); + idx++; + } + } + return result; + } + + + /** + * The tree children of a channel: membership, sender, receiver and the (user configured) interceptors. + */ + private List> channelChildren(ManagedChannel channel, String parentId) { + List> result = new ArrayList<>(); + MembershipService membership = channel.getMembershipService(); + if (membership != null) { + result.add(build(membership, parentId + "/membership/0", "membership")); + } + ChannelSender sender = channel.getChannelSender(); + if (sender != null) { + result.add(build(sender, parentId + "/sender/0", "sender")); + } + ChannelReceiver receiver = channel.getChannelReceiver(); + if (receiver != null) { + result.add(build(receiver, parentId + "/receiver/0", "receiver")); + } + Iterator interceptors = channel.getInterceptors(); + int i = 0; + while (interceptors.hasNext()) { + result.add(build(interceptors.next(), parentId + "/interceptor/" + i, "interceptor")); + i++; + } + return result; + } + + + /** + * The tree children of a channel sender: the transport (a replication transmitter holds one). + */ + private List> senderChildren(ChannelSender sender, String parentId) { + List> result = new ArrayList<>(); + if (sender instanceof ReplicationTransmitter transmitter) { + MultiPointSender transport = transmitter.getTransport(); + if (transport != null) { + result.add(build(transport, parentId + "/transport/0", "transport")); + } + } + return result; + } + + + /** + * The tree children of a membership service: the static members when the service is a static membership service. + */ + private List> membershipChildren(MembershipService membership, String parentId) { + List> result = new ArrayList<>(); + if (membership instanceof StaticMembershipService sms) { + Member local = sms.getLocalMember(false); + if (local != null) { + result.add(build(local, parentId + "/localMember/0", "member")); + } + List members = sms.getStaticMembers(); + for (int i = 0; i < members.size(); i++) { + result.add(build(members.get(i), parentId + "/member/" + i, "member")); + } + } + return result; + } + + + /** + * The tree children of an interceptor: the local member when the interceptor is a static membership interceptor. + */ + private List> interceptorChildren(ChannelInterceptor interceptor, String parentId) { + List> result = new ArrayList<>(); + if (interceptor instanceof StaticMembershipInterceptor smi) { + Member local = smi.getLocalMember(false); + if (local != null) { + result.add(build(local, parentId + "/localMember/0", "member")); + } + } + return result; + } + + + private static String memberLabel(Member member) { + byte[] hostBytes = member.getHost(); + String host = hostBytes == null ? null : new String(hostBytes); + if (host == null || host.isEmpty()) { + return member.getName(); + } + return host + ":" + member.getPort(); + } + + + private static String displayName(Object component, String type) { + if (component instanceof Service s) { + return s.getName(); + } + if (component instanceof Engine e) { + return e.getName(); + } + if (component instanceof Host h) { + return h.getName(); + } + if (component instanceof Context c) { + // The root context has the empty path; display it as "/". + String path = c.getPath(); + return path.isEmpty() ? "/" : path; + } + if (component instanceof Wrapper w) { + return w.getName(); + } + if (component instanceof Executor e) { + return e.getName(); + } + if (component instanceof Connector connector) { + return connectorLabel(connector); + } + if (component instanceof SSLHostConfig hostConfig) { + return hostConfig.getHostName(); + } + if (component instanceof SSLHostConfigCertificate certificate) { + return certificateLabel(certificate); + } + if (component instanceof ResourceBase entry) { + return entry.getName(); + } + if (component instanceof Member member) { + return memberLabel(member); + } + return component.getClass().getSimpleName(); + } + + + // --------------------------------------------------------- Node details + + + private Map node(NodeRef ref) throws ConfigException { + + Object component = ref.component; + + Map out = new LinkedHashMap<>(); + out.put("id", ref.id); + out.put("type", ref.type); + out.put("name", ref.aliasValue != null ? ref.aliasValue : displayName(component, ref.type)); + out.put("className", component.getClass().getName()); + if (component instanceof Lifecycle lifecycle) { + out.put("state", lifecycle.getState().toString()); + } + if ("context".equals(ref.type) && component == selfContext) { + out.put("self", Boolean.TRUE); + } + // Whether a lifecycle listener can be added to this component. + // Not derivable on the client for a listener node, whose class may + // or may not implement Lifecycle (e.g. a valve used as a listener). + // An alias is excluded: its component is the parent host (an alias + // is just a name, not a component that holds listeners). + out.put("acceptsListener", + !"alias".equals(ref.type) && !CLUSTER_SUB_TYPES.contains(ref.type) && component instanceof Lifecycle); + + if ("connector".equals(ref.type) && component instanceof Connector connector) { + out.put("sslEnabled", isSslEnabled(connector)); + } + if ("realm".equals(ref.type)) { + // Only combined realms can hold (sub) realms. Not derivable on + // the client from the class name alone (subclasses). + out.put("acceptsSubRealm", component instanceof CombinedRealm); + } + if ("namingResources".equals(ref.type) && component instanceof NamingResourcesImpl namingResources) { + // The server level node does not accept resourceLink entries. + out.put("global", Boolean.valueOf(namingResources.getContainer() instanceof Server)); + } + if ("sslHostConfig".equals(ref.type) && component instanceof SSLHostConfig hostConfig) { + Connector owner = connectorOfSsl(hostConfig); + if (owner != null) { + try { + out.put("isDefault", Boolean + .valueOf(endpointOf(owner).getDefaultSSLHostConfigName().equals(hostConfig.getHostName()))); + } catch (ConfigException e) { + // No endpoint: leave isDefault out. + } + } + } + + List> properties = new ArrayList<>(); + // Some components have no modeler descriptor; their attribute + // list is defined explicitly. They are handled before the + // descriptor branch because the registry falls back to an + // introspected ManagedBean for them, which would otherwise + // shadow the (more complete) explicit list. + List explicit = explicitAttributes(component, ref.type); + if (!explicit.isEmpty()) { + for (ExplicitAttribute attribute : explicit) { + Map entry = new LinkedHashMap<>(); + entry.put("name", attribute.getName()); + entry.put("type", attribute.getType()); + if (attribute.getDescription() != null) { + entry.put("description", attribute.getDescription()); + } + entry.put("writable", Boolean.valueOf(attribute.isWritable())); + if (attribute.isParam()) { + entry.put("param", Boolean.TRUE); + } + entry.put("value", jsonSafe(readExplicitValue(component, attribute))); + properties.add(entry); + } + } else { + ManagedBean descriptor = descriptor(component); + if (descriptor != null) { + for (AttributeInfo attribute : descriptor.getAttributes()) { + if (!attribute.isReadable()) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", attribute.getName()); + entry.put("type", attribute.getType()); + if (attribute.getDescription() != null) { + entry.put("description", attribute.getDescription()); + } + entry.put("writable", + Boolean.valueOf(attribute.isWriteable() && EDITABLE_TYPES.contains(attribute.getType()))); + entry.put("value", jsonSafe(readValue(component, attribute))); + properties.add(entry); + } + } + } + if ("connector".equals(ref.type) && component instanceof Connector connector) { + Map entry = new LinkedHashMap<>(); + entry.put("name", "sslEnabled"); + entry.put("type", "boolean"); + entry.put("writable", Boolean.FALSE); + entry.put("value", Boolean.valueOf(isSslEnabled(connector))); + properties.add(entry); + } + out.put("properties", properties); + + // The direct children of this node (one level, no recursion) + Map built = build(component, ref.id, ref.type); + out.put("children", built.get("children")); + return out; + } + + + /** + * The modeler descriptor (MBean contract) of the component's class, or {@code null} for classes without a + * descriptor. + */ + private static ManagedBean descriptor(Object component) { + return Registry.getRegistry(null).findManagedBean(component.getClass().getName()); + } + + + private static AttributeInfo findAttribute(ManagedBean descriptor, String name) { + if (descriptor != null) { + for (AttributeInfo attribute : descriptor.getAttributes()) { + if (attribute.getName().equals(name)) { + return attribute; + } + } + } + return null; + } + + + private static Object readValue(Object component, AttributeInfo attribute) { + String method = attribute.getGetMethod(); + if (method == null) { + method = ("boolean".equals(attribute.getType()) || attribute.isIs()) + ? "is" + capitalize(attribute.getName()) + : "get" + capitalize(attribute.getName()); + } + try { + Method m = component.getClass().getMethod(method); + return m.invoke(component); + } catch (Exception e) { + return null; + } + } + + + // ------------------------------------- Explicit component attributes + + + /** + * One explicitly defined attribute of a component whose class has no modeler MBean descriptor (the TLS components + * {@code SSLHostConfig}/{@code SSLHostConfigCertificate} and the context sub components {@code WebappLoader}, + * {@code CookieProcessorBase} and {@code SessionIdGeneratorBase}), defined here instead of being derived. + */ + private static final class ExplicitAttribute { + + private final String name; + + private final String type; + + private final boolean writable; + + private final String description; + + private final boolean param; + + + ExplicitAttribute(String name, String type, boolean writable, String description) { + this(name, type, writable, description, false); + } + + + ExplicitAttribute(String name, String type, boolean writable, String description, boolean param) { + this.name = name; + this.type = type; + this.writable = writable; + this.description = description; + this.param = param; + } + + + String getName() { + return name; + } + + + String getType() { + return type; + } + + + boolean isWritable() { + return writable; + } + + + String getDescription() { + return description; + } + + + /** + * {@code true} for attributes that are not bean properties but string parameters of the generic property map of + * a JNDI entry (the {@code ResourceBase} properties). + */ + boolean isParam() { + return param; + } + } + + + private static final List SSL_HOST_CONFIG_ATTRIBUTES = List.of( + new ExplicitAttribute("hostName", "java.lang.String", false, + "The SNI host name this configuration applies to (lower case)."), + new ExplicitAttribute("protocols", "java.lang.String", true, + "Enabled TLS protocols, e.g. TLSv1.2+TLSv1.3, or All."), + new ExplicitAttribute("certificateVerification", "java.lang.String", true, + "Client certificate verification: none, optional, optionalNoCA or required."), + new ExplicitAttribute("certificateVerificationDepth", "int", true, + "The depth of the client certificate chain verification."), + new ExplicitAttribute("ciphers", "java.lang.String", true, + "The cipher list for TLS 1.2 and below (OpenSSL or JSSE names)."), + new ExplicitAttribute("cipherSuites", "java.lang.String", true, "The cipher suite list for TLS 1.3."), + new ExplicitAttribute("honorCipherOrder", "boolean", true, "Whether to honor the server cipher order."), + new ExplicitAttribute("sessionCacheSize", "int", true, "The SSL session cache size."), + new ExplicitAttribute("sessionTimeout", "int", true, "The SSL session timeout in seconds."), + new ExplicitAttribute("groups", "java.lang.String", true, "The enabled named groups (comma separated)."), + new ExplicitAttribute("keyManagerAlgorithm", "java.lang.String", true, "The key manager algorithm (JSSE)."), + new ExplicitAttribute("sslProtocol", "java.lang.String", true, "The SSL protocol (JSSE)."), + new ExplicitAttribute("revocationEnabled", "boolean", true, + "Whether CRL/OCSP revocation checking is enabled (JSSE)."), + new ExplicitAttribute("trustManagerClassName", "java.lang.String", true, + "The trust manager class name (JSSE)."), + new ExplicitAttribute("truststoreAlgorithm", "java.lang.String", true, "The truststore algorithm (JSSE)."), + new ExplicitAttribute("truststoreFile", "java.lang.String", true, "The truststore file (JSSE)."), + new ExplicitAttribute("truststorePassword", "java.lang.String", true, "The truststore password (JSSE)."), + new ExplicitAttribute("truststoreProvider", "java.lang.String", true, "The truststore provider (JSSE)."), + new ExplicitAttribute("truststoreType", "java.lang.String", true, "The truststore type (JSSE)."), + new ExplicitAttribute("caCertificateFile", "java.lang.String", true, "The CA certificate file (OpenSSL)."), + new ExplicitAttribute("caCertificatePath", "java.lang.String", true, + "The CA certificate directory (OpenSSL)."), + new ExplicitAttribute("certificateRevocationListPath", "java.lang.String", true, + "The certificate revocation list directory (OpenSSL)."), + new ExplicitAttribute("disableCompression", "boolean", true, + "Whether TLS compression is disabled (OpenSSL)."), + new ExplicitAttribute("disableSessionTickets", "boolean", true, + "Whether TLS session tickets are disabled (OpenSSL)."), + new ExplicitAttribute("insecureRenegotiation", "boolean", true, + "Whether insecure renegotiation is allowed (OpenSSL)")); + + + private static final List CERTIFICATE_ATTRIBUTES = List.of( + new ExplicitAttribute("type", "java.lang.String", false, + "The certificate type (the default certificate has no type)."), + new ExplicitAttribute("certificateKeystoreFile", "java.lang.String", true, + "The keystore file (JKS or PKCS12)."), + new ExplicitAttribute("certificateKeystorePassword", "java.lang.String", true, "The keystore password."), + new ExplicitAttribute("certificateKeystorePasswordFile", "java.lang.String", true, + "The file that contains the keystore password."), + new ExplicitAttribute("certificateKeystoreType", "java.lang.String", true, + "The keystore type (e.g. PKCS12)."), + new ExplicitAttribute("certificateKeystoreProvider", "java.lang.String", true, "The keystore provider."), + new ExplicitAttribute("certificateKeyAlias", "java.lang.String", true, + "The alias of the key entry in the keystore."), + new ExplicitAttribute("certificateKeyPassword", "java.lang.String", true, + "The private key password (if different from the keystore password)."), + new ExplicitAttribute("certificateKeyPasswordFile", "java.lang.String", true, + "The file that contains the private key password."), + new ExplicitAttribute("certificateFile", "java.lang.String", true, "The certificate file (PEM, OpenSSL)."), + new ExplicitAttribute("certificateChainFile", "java.lang.String", true, + "The certificate chain file (PEM, OpenSSL)."), + new ExplicitAttribute("certificateKeyFile", "java.lang.String", true, + "The private key file (PEM, OpenSSL)")); + + + private static final List WEBAPP_LOADER_ATTRIBUTES = List.of( + new ExplicitAttribute("delegate", "boolean", true, + "Whether the web application class loader delegates to the parent class loader first."), + new ExplicitAttribute("loaderClass", "java.lang.String", true, + "The class of the web application class loader instance."), + new ExplicitAttribute("jakartaConverter", "java.lang.String", true, + "The class that converts Jakarta Servlet API classes to their equivalent in the deployed application.")); + + + private static final List COOKIE_PROCESSOR_ATTRIBUTES = List.of( + new ExplicitAttribute("cookiesWithoutEquals", "java.lang.String", true, + "How to handle cookie names without an equals sign in the cookie header."), + new ExplicitAttribute("sameSiteCookies", "java.lang.String", true, + "The SameSite attribute added to the cookies of this web application (Unset, None, Lax or Strict)."), + new ExplicitAttribute("partitioned", "boolean", true, + "Whether the Partitioned attribute is added to the cookies of this web application.")); + + + private static final List SESSION_ID_GENERATOR_ATTRIBUTES = List.of( + new ExplicitAttribute("secureRandomClass", "java.lang.String", true, + "The secure random number generator class used to create the session ids."), + new ExplicitAttribute("jvmRoute", "java.lang.String", true, + "The jvm route appended to the generated session ids (cluster failover)."), + new ExplicitAttribute("sessionIdLength", "int", true, "The length of the generated session ids in bytes.")); + + + // --------------------------------- Cluster channel attributes + + // The sub components of a cluster channel (GroupChannel, the multicast + // membership service, the receiver, the sender transport and the + // interceptors) have no modeler descriptors, so their attributes are + // defined explicitly. Only the common, documented knobs are listed. + + private static final List CHANNEL_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The name of the channel."), + new ExplicitAttribute("heartbeat", "boolean", true, + "Whether the channel manages its own heartbeat thread."), + new ExplicitAttribute("heartbeatSleeptime", "long", true, + "The interval in milliseconds between heartbeats."), + new ExplicitAttribute("jmxDomain", "java.lang.String", true, "The JMX domain for the channel components."), + new ExplicitAttribute("jmxPrefix", "java.lang.String", true, + "The JMX name prefix for the channel components."), + new ExplicitAttribute("optionCheck", "boolean", true, + "Whether to check that the channel is correctly configured before starting.")); + + private static final List MCAST_ATTRIBUTES = List.of( + new ExplicitAttribute("address", "java.lang.String", true, + "The multicast address to join (e.g. 228.0.0.4)."), + new ExplicitAttribute("port", "int", true, "The multicast port to join (e.g. 45564)."), + new ExplicitAttribute("frequency", "long", true, + "The interval in milliseconds between membership messages."), + new ExplicitAttribute("dropTime", "long", true, + "The time in milliseconds a member may be silent before being dropped."), + new ExplicitAttribute("ttl", "int", true, "The time to live for multicast packets."), + new ExplicitAttribute("soTimeout", "int", true, "The socket timeout in milliseconds."), + new ExplicitAttribute("recoveryEnabled", "boolean", true, "Whether membership recovery is enabled."), + new ExplicitAttribute("recoverySleepTime", "long", true, + "The sleep time in milliseconds between recovery attempts."), + new ExplicitAttribute("localLoopbackDisabled", "boolean", true, + "Whether local loopback of multicast packets is disabled.")); + + private static final List RECEIVER_ATTRIBUTES = List.of( + new ExplicitAttribute("port", "int", true, "The TCP port to listen on."), + new ExplicitAttribute("autoBind", "int", true, "The number of attempts to auto bind an available port."), + new ExplicitAttribute("address", "java.lang.String", true, "The address to bind to."), + new ExplicitAttribute("udpPort", "int", true, "The UDP port to listen on."), + new ExplicitAttribute("maxThreads", "int", true, "The maximum number of listener threads."), + new ExplicitAttribute("minThreads", "int", true, "The minimum number of listener threads."), + new ExplicitAttribute("selectorTimeout", "long", true, "The selector timeout in milliseconds."), + new ExplicitAttribute("tcpNoDelay", "boolean", true, "Whether TCP_NODELAY is set on the sockets."), + new ExplicitAttribute("soKeepAlive", "boolean", true, "Whether SO_KEEPALIVE is set on the sockets."), + new ExplicitAttribute("soReuseAddress", "boolean", true, "Whether SO_REUSEADDR is set on the sockets.")); + + private static final List TRANSPORT_ATTRIBUTES = List.of( + new ExplicitAttribute("poolSize", "int", true, "The number of sockets in the pool (pooled senders)."), + new ExplicitAttribute("timeout", "long", true, "The socket timeout in milliseconds."), + new ExplicitAttribute("maxRetryAttempts", "int", true, "The number of attempts to retransmit a message."), + new ExplicitAttribute("udpPort", "int", true, "The UDP port to send to."), + new ExplicitAttribute("directBuffer", "boolean", true, "Whether to use direct (off heap) buffers."), + new ExplicitAttribute("tcpNoDelay", "boolean", true, "Whether TCP_NODELAY is set on the sockets."), + new ExplicitAttribute("soKeepAlive", "boolean", true, "Whether SO_KEEPALIVE is set on the sockets."), + new ExplicitAttribute("soReuseAddress", "boolean", true, "Whether SO_REUSEADDR is set on the sockets.")); + + private static final List MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES = List.of( + new ExplicitAttribute("maxQueueSize", "long", true, "The maximum number of messages to queue."), + new ExplicitAttribute("maxThreads", "int", true, "The maximum number of threads in the dispatch pool."), + new ExplicitAttribute("maxSpareThreads", "int", true, "The maximum number of idle threads to keep."), + new ExplicitAttribute("keepAliveTime", "long", true, + "The keep alive time in milliseconds for idle threads."), + new ExplicitAttribute("useDeepClone", "boolean", true, "Whether messages are deep cloned before dispatch."), + new ExplicitAttribute("alwaysSend", "boolean", true, + "Whether to always send messages even with no other members.")); + + private static final List TCP_FAILURE_DETECTOR_ATTRIBUTES = List.of( + new ExplicitAttribute("connectTimeout", "long", true, + "The timeout in milliseconds for the connection test."), + new ExplicitAttribute("readTestTimeout", "long", true, "The timeout in milliseconds for the read test."), + new ExplicitAttribute("performSendTest", "boolean", true, "Whether to perform a send test."), + new ExplicitAttribute("performReadTest", "boolean", true, "Whether to perform a read test."), + new ExplicitAttribute("removeSuspectsTimeout", "int", true, + "The time in milliseconds a suspect member is kept before removal.")); + + private static final List INTERCEPTOR_ATTRIBUTES = List.of(new ExplicitAttribute("optionFlag", + "int", true, "The option flag that controls the behaviour of the interceptor.")); + + + // ------------------------------------------------- JNDI entry attributes + + // The JNDI entry types (children of a namingResources node). + + private static final List RESOURCE_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the resource (e.g. jdbc/MyDB)."), + new ExplicitAttribute("type", "java.lang.String", true, + "The type of the object to look up (e.g. javax.sql.DataSource)."), + new ExplicitAttribute("auth", "java.lang.String", true, + "The JNDI authentication mode (Application or Container)."), + new ExplicitAttribute("scope", "java.lang.String", true, + "The JNDI scope of the resource (Shareable or Unshareable)."), + new ExplicitAttribute("singleton", "boolean", true, + "Whether the resource is a shared, long lived instance."), + new ExplicitAttribute("closeMethod", "java.lang.String", true, + "The method invoked to close the resource when it is unbound."), + new ExplicitAttribute("lookupName", "java.lang.String", true, + "A JNDI name to look up; when set, the other parameters are ignored."), + new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource.")); + + private static final List RESOURCE_LINK_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The local JNDI name of the resource link."), + new ExplicitAttribute("type", "java.lang.String", true, "The type of the object the link resolves to."), + new ExplicitAttribute("global", "java.lang.String", true, + "The JNDI name of the (global) resource the link points to."), + new ExplicitAttribute("factory", "java.lang.String", true, + "The JNDI ObjectFactory used to resolve the global resource."), + new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource link.")); + + private static final List RESOURCE_ENV_REF_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the resource."), + new ExplicitAttribute("type", "java.lang.String", true, "The type of the object to look up."), + new ExplicitAttribute("override", "boolean", true, + "Whether the context environment entry overrides a global resource with the same name."), + new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource.")); + + private static final List ENVIRONMENT_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the environment entry."), + new ExplicitAttribute("type", "java.lang.String", true, + "The type of the entry (e.g. java.lang.String, javax.sql.DataSource)."), + new ExplicitAttribute("value", "java.lang.String", true, "The value of the entry."), + new ExplicitAttribute("override", "boolean", true, + "Whether the context entry overrides a global entry with the same name."), + new ExplicitAttribute("description", "java.lang.String", true, "The description of the entry.")); + + private static final List EJB_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the EJB reference."), + new ExplicitAttribute("type", "java.lang.String", true, "The fully qualified name of the home interface."), + new ExplicitAttribute("home", "java.lang.String", true, + "The fully qualified name of the home interface (alternative to type)."), + new ExplicitAttribute("link", "java.lang.String", true, + "The JNDI name of the remote EJB the reference links to."), + new ExplicitAttribute("remote", "java.lang.String", true, + "The fully qualified name of the remote interface."), + new ExplicitAttribute("description", "java.lang.String", true, "The description of the EJB reference.")); + + private static final List LOCAL_EJB_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the local EJB reference."), + new ExplicitAttribute("type", "java.lang.String", true, + "The fully qualified name of the local home interface."), + new ExplicitAttribute("local", "java.lang.String", true, + "The fully qualified name of the local business interface."), + new ExplicitAttribute("home", "java.lang.String", true, + "The fully qualified name of the local home interface (alternative to type)."), + new ExplicitAttribute("link", "java.lang.String", true, + "The JNDI name of the local EJB the reference links to."), + new ExplicitAttribute("description", "java.lang.String", true, + "The description of the local EJB reference.")); + + private static final List SERVICE_ATTRIBUTES = List.of( + new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the service reference."), + new ExplicitAttribute("type", "java.lang.String", true, + "The fully qualified name of the service interface."), + new ExplicitAttribute("interface", "java.lang.String", true, + "The fully qualified name of the service interface (alternative to type)."), + new ExplicitAttribute("displayname", "java.lang.String", true, + "The display name of the service reference."), + new ExplicitAttribute("wsdlfile", "java.lang.String", true, "The WSDL document of the service."), + new ExplicitAttribute("description", "java.lang.String", true, + "The description of the service reference.")); + + // The first party JNDI ObjectFactory implementations shipped with + // Tomcat and the string parameters (RefAddr keys) each one consumes. + + private static final String BASIC_DATA_SOURCE_FACTORY = "org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory"; + + private static final String MEMORY_USER_DATABASE_FACTORY = "org.apache.catalina.users.MemoryUserDatabaseFactory"; + + private static final String DATA_SOURCE_USER_DATABASE_FACTORY = "org.apache.catalina.users.DataSourceUserDatabaseFactory"; + + private static final String PER_USER_POOL_DATA_SOURCE_FACTORY = "org.apache.tomcat.dbcp.dbcp2.datasources.PerUserPoolDataSourceFactory"; + + private static final String SHARED_POOL_DATA_SOURCE_FACTORY = "org.apache.tomcat.dbcp.dbcp2.datasources.SharedPoolDataSourceFactory"; + + private static final List BASIC_DATA_SOURCE_FACTORY_OPTIONS = List.of( + option("defaultAutoCommit", "boolean", "The default auto commit mode of the connections."), + option("defaultReadOnly", "boolean", "The default read only mode of the connections."), + option("defaultTransactionIsolation", "java.lang.String", + "The default transaction isolation level (NONE, READ_COMMITTED, READ_UNCOMMITTED, REPEATABLE_READ, SERIALIZABLE or a JDBC constant)."), + option("defaultCatalog", "java.lang.String", "The default catalog of the connections."), + option("defaultSchema", "java.lang.String", "The default schema of the connections."), + option("cacheState", "boolean", "Whether to cache the connection state on the wrapper."), + option("driverClassName", "java.lang.String", "The JDBC driver class name."), + option("lifo", "boolean", "Whether to allocate idle connections in LIFO order."), + option("maxTotal", "int", "The maximum number of active connections in the pool."), + option("maxIdle", "int", "The maximum number of idle connections in the pool."), + option("minIdle", "int", "The minimum number of idle connections to retain."), + option("initialSize", "int", "The number of connections created at pool startup."), + option("maxWaitMillis", "long", "The maximum time in milliseconds to wait for a connection."), + option("testOnCreate", "boolean", "Whether to validate a connection when it is created."), + option("testOnBorrow", "boolean", "Whether to validate a connection when it is borrowed."), + option("testOnReturn", "boolean", "Whether to validate a connection when it is returned."), + option("timeBetweenEvictionRunsMillis", "long", "The time in milliseconds between eviction runs."), + option("numTestsPerEvictionRun", "int", "The number of connections tested per eviction run."), + option("minEvictableIdleTimeMillis", "long", + "The minimum idle time in milliseconds before a connection is evicted."), + option("softMinEvictableIdleTimeMillis", "long", "The soft minimum idle time in milliseconds."), + option("evictionPolicyClassName", "java.lang.String", "The class of the idle object eviction policy."), + option("testWhileIdle", "boolean", "Whether to validate connections while they are idle."), + option("password", "java.lang.String", "The JDBC connection password."), + option("url", "java.lang.String", "The JDBC connection URL."), + option("username", "java.lang.String", "The JDBC connection user name."), + option("validationQuery", "java.lang.String", + "The SQL query (or callable statement) used to validate connections."), + option("validationQueryTimeout", "long", "The timeout in seconds for the validation query."), + option("connectionInitSqls", "java.lang.String", + "Semicolon separated statements executed on each new connection."), + option("accessToUnderlyingConnectionAllowed", "boolean", + "Whether the underlying driver connection can be obtained."), + option("removeAbandonedOnBorrow", "boolean", "Whether abandoned connections are removed on borrow."), + option("removeAbandonedOnMaintenance", "boolean", + "Whether abandoned connections are removed during maintenance."), + option("removeAbandonedTimeout", "long", + "The timeout in seconds after which a connection is considered abandoned."), + option("logAbandoned", "boolean", "Whether to log the stack trace of abandoned connections."), + option("abandonedUsageTracking", "java.lang.String", + "The stack trace tracking mode for abandoned connections."), + option("poolPreparedStatements", "boolean", "Whether prepared statements are pooled."), + option("clearStatementPoolOnReturn", "boolean", + "Whether the statement pool is cleared when the connection is returned."), + option("maxOpenPreparedStatements", "int", + "The maximum number of pooled prepared statements per connection."), + option("connectionProperties", "java.lang.String", + "Semicolon separated key=value pairs passed to the driver."), + option("maxConnLifetimeMillis", "long", "The maximum lifetime in milliseconds of a connection."), + option("logExpiredConnections", "boolean", "Whether to log the expiration of pooled connections."), + option("rollbackOnReturn", "boolean", "Whether to roll back uncommitted transactions on return."), + option("enableAutoCommitOnReturn", "boolean", "Whether to re-enable auto commit on return."), + option("defaultQueryTimeout", "long", "The default query timeout in seconds."), + option("fastFailValidation", "boolean", "Whether validation fails fast on a known dead connection."), + option("disconnectionSqlCodes", "java.lang.String", + "Comma separated SQL state codes treated as disconnections."), + option("disconnectionIgnoreSqlCodes", "java.lang.String", + "Comma separated SQL state codes ignored during disconnection checks."), + option("jmxName", "java.lang.String", "The JMX ObjectName under which the pool is registered."), + option("registerConnectionMBean", "boolean", "Whether each connection is registered as an MBean."), + option("connectionFactoryClassName", "java.lang.String", + "A custom connection factory class (instead of the driver).")); + + private static final List MEMORY_USER_DATABASE_FACTORY_OPTIONS = List.of( + option("pathname", "java.lang.String", "The path of the XML user file (default conf/tomcat-users.xml)."), + option("readonly", "boolean", "Whether the user database is read only."), + option("watchSource", "boolean", "Whether the user file is watched for changes and reloaded.")); + + private static final List DATA_SOURCE_USER_DATABASE_FACTORY_OPTIONS = List.of( + option("dataSourceName", "java.lang.String", "The JNDI name of the DataSource to use."), + option("readonly", "boolean", "Whether the user database is read only."), + option("userTable", "java.lang.String", "The name of the user table."), + option("groupTable", "java.lang.String", "The name of the group table."), + option("roleTable", "java.lang.String", "The name of the role table."), + option("userRoleTable", "java.lang.String", "The name of the user/role mapping table."), + option("userGroupTable", "java.lang.String", "The name of the user/group mapping table."), + option("groupRoleTable", "java.lang.String", "The name of the group/role mapping table."), + option("roleNameCol", "java.lang.String", "The column name of the role name."), + option("roleAndGroupDescriptionCol", "java.lang.String", "The column name of the role/group description."), + option("groupNameCol", "java.lang.String", "The column name of the group name."), + option("userCredCol", "java.lang.String", "The column name of the user credential (password)."), + option("userFullNameCol", "java.lang.String", "The column name of the user full name."), + option("userNameCol", "java.lang.String", "The column name of the user name.")); + + private static final List POOL_DATA_SOURCE_FACTORY_OPTIONS = List.of( + option("instanceKey", "java.lang.String", + "The unique key of this pool instance (defaults to the JNDI name)."), + option("description", "java.lang.String", "A description of the pool."), + option("loginTimeout", "int", "The login timeout in seconds."), + option("blockWhenExhausted", "boolean", "Whether to block when the pool is exhausted."), + option("evictionPolicyClassName", "java.lang.String", "The class of the idle object eviction policy."), + option("lifo", "boolean", "Whether to allocate idle connections in LIFO order."), + option("maxIdlePerKey", "int", "The maximum number of idle connections per instance key."), + option("maxTotalPerKey", "int", "The maximum number of active connections per instance key."), + option("maxWaitMillis", "long", "The maximum time in milliseconds to wait for a connection."), + option("minEvictableIdleTimeMillis", "long", + "The minimum idle time in milliseconds before a connection is evicted."), + option("minIdlePerKey", "int", "The minimum number of idle connections per instance key."), + option("numTestsPerEvictionRun", "int", "The number of connections tested per eviction run."), + option("softMinEvictableIdleTimeMillis", "long", "The soft minimum idle time in milliseconds."), + option("testOnCreate", "boolean", "Whether to validate a connection when it is created."), + option("testOnBorrow", "boolean", "Whether to validate a connection when it is borrowed."), + option("testOnReturn", "boolean", "Whether to validate a connection when it is returned."), + option("testWhileIdle", "boolean", "Whether to validate connections while they are idle."), + option("timeBetweenEvictionRunsMillis", "long", "The time in milliseconds between eviction runs."), + option("validationQuery", "java.lang.String", "The SQL query used to validate connections."), + option("validationQueryTimeout", "int", "The timeout in seconds for the validation query."), + option("rollbackAfterValidation", "boolean", "Whether to roll back after a validation query."), + option("maxConnLifetimeMillis", "long", "The maximum lifetime in milliseconds of a connection."), + option("defaultAutoCommit", "boolean", "The default auto commit mode of the connections."), + option("defaultTransactionIsolation", "int", "The default transaction isolation level (JDBC constant)."), + option("defaultReadOnly", "boolean", "The default read only mode of the connections.")); + + private static final List PER_USER_POOL_DATA_SOURCE_FACTORY_OPTIONS = poolOptions( + option("defaultMaxTotal", "int", "The default maximum number of connections per user."), + option("defaultMaxIdle", "int", "The default maximum number of idle connections per user."), + option("defaultMaxWaitMillis", "long", "The default maximum wait time in milliseconds per user.")); + + private static final List SHARED_POOL_DATA_SOURCE_FACTORY_OPTIONS = poolOptions( + option("maxTotal", "int", "The maximum number of active connections in the pool.")); + + private static List poolOptions(ExplicitAttribute... first) { + List result = new ArrayList<>(Arrays.asList(first)); + result.addAll(POOL_DATA_SOURCE_FACTORY_OPTIONS); + return List.copyOf(result); + } + + private static ExplicitAttribute option(String name, String type, String description) { + return new ExplicitAttribute(name, type, true, description, true); + } + + + /** + * The factory options (RefAddr keys) of the given first party JNDI factory, or {@code null} when the factory is not + * one of the factories shipped with Tomcat (their parameters stay free form). + */ + private static List factoryOptions(String factory) { + if (factory == null) { + return null; + } + return switch (factory) { + case BASIC_DATA_SOURCE_FACTORY -> BASIC_DATA_SOURCE_FACTORY_OPTIONS; + case MEMORY_USER_DATABASE_FACTORY -> MEMORY_USER_DATABASE_FACTORY_OPTIONS; + case DATA_SOURCE_USER_DATABASE_FACTORY -> DATA_SOURCE_USER_DATABASE_FACTORY_OPTIONS; + case PER_USER_POOL_DATA_SOURCE_FACTORY -> PER_USER_POOL_DATA_SOURCE_FACTORY_OPTIONS; + case SHARED_POOL_DATA_SOURCE_FACTORY -> SHARED_POOL_DATA_SOURCE_FACTORY_OPTIONS; + default -> null; + }; + } + + + /** + * The factory a resource resolves to: the explicit {@code factory} parameter, or the default factory the + * {@code ResourceFactory} dispatches the resource type to. + */ + private static String effectiveFactory(ContextResource resource) { + Object factory = resource.getProperty("factory"); + if (factory != null && !String.valueOf(factory).isEmpty()) { + return String.valueOf(factory); + } + // Default dispatch of org.apache.naming.factory.ResourceFactory. + if ("javax.sql.DataSource".equals(resource.getType())) { + return BASIC_DATA_SOURCE_FACTORY; + } + return null; + } + + + private static boolean isNamingEntry(String type) { + return "resource".equals(type) || "resourceLink".equals(type) || "resourceEnvRef".equals(type) || + "environment".equals(type) || "ejb".equals(type) || "localEjb".equals(type) || + "serviceRef".equals(type); + } + + + /** + * The attribute list of one JNDI entry: the core attributes of the entry type, plus - for a resource whose factory + * is one of the first party factories with a closed set of parameters - the factory options, plus the free form + * parameters that are set but not covered by the list. + */ + private static List namingEntryAttributes(Object component, String type) { + List result; + if ("resource".equals(type)) { + ContextResource resource = (ContextResource) component; + result = new ArrayList<>(RESOURCE_ATTRIBUTES); + List options = factoryOptions(effectiveFactory(resource)); + if (options != null) { + result.addAll(options); + } + } else { + result = new ArrayList<>(switch (type) { + case "resourceLink" -> RESOURCE_LINK_ATTRIBUTES; + case "resourceEnvRef" -> RESOURCE_ENV_REF_ATTRIBUTES; + case "environment" -> ENVIRONMENT_ATTRIBUTES; + case "ejb" -> EJB_ATTRIBUTES; + case "localEjb" -> LOCAL_EJB_ATTRIBUTES; + case "serviceRef" -> SERVICE_ATTRIBUTES; + default -> List.of(); + }); + } + // The string parameters of the entry (the ResourceBase properties) + // that are not covered by the explicit attribute tables above. + appendParams((ResourceBase) component, result); + return result; + } + + + /** + * Append the string parameters of the given JNDI entry that are not already part of its explicit attribute list. + */ + private static void appendParams(ResourceBase entry, List result) { + Set covered = new HashSet<>(); + for (ExplicitAttribute attribute : result) { + covered.add(attribute.getName()); + } + for (Iterator it = entry.listProperties(); it.hasNext();) { + String key = it.next(); + if (covered.add(key)) { + result.add(new ExplicitAttribute(key, "java.lang.String", true, null, true)); + } + } + } + + + /** + * The explicitly defined attributes of the given component, or an empty list when the component's class has a + * modeler descriptor (or is not one of the explicitly supported classes). + */ + private static List explicitAttributes(Object component, String type) { + if ("sslHostConfig".equals(type)) { + return SSL_HOST_CONFIG_ATTRIBUTES; + } + if ("certificate".equals(type)) { + return CERTIFICATE_ATTRIBUTES; + } + if (component instanceof WebappLoader) { + return WEBAPP_LOADER_ATTRIBUTES; + } + if (component instanceof CookieProcessorBase) { + return COOKIE_PROCESSOR_ATTRIBUTES; + } + if (component instanceof SessionIdGeneratorBase) { + return SESSION_ID_GENERATOR_ATTRIBUTES; + } + if (component instanceof GroupChannel) { + return CHANNEL_ATTRIBUTES; + } + if (component instanceof McastService) { + return MCAST_ATTRIBUTES; + } + if (component instanceof ReceiverBase) { + return RECEIVER_ATTRIBUTES; + } + if (component instanceof AbstractSender) { + return TRANSPORT_ATTRIBUTES; + } + if (component instanceof MessageDispatchInterceptor) { + return MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES; + } + if (component instanceof TcpFailureDetector) { + return TCP_FAILURE_DETECTOR_ATTRIBUTES; + } + if (component instanceof ChannelInterceptor) { + return INTERCEPTOR_ATTRIBUTES; + } + if (isNamingEntry(type) && component instanceof ResourceBase) { + return namingEntryAttributes(component, type); + } + return List.of(); + } + + + private static ExplicitAttribute findExplicitAttribute(Object component, String type, String name) { + for (ExplicitAttribute attribute : explicitAttributes(component, type)) { + if (attribute.getName().equals(name)) { + return attribute; + } + } + return null; + } + + + /** + * Read the value of one explicitly defined attribute. A few attributes need a dedicated reader: the protocol set is + * shown as the {@code +} separated list that {@code setProtocols} accepts, and the certificate verification level + * as its string form. + */ + private static Object readExplicitValue(Object component, ExplicitAttribute attribute) { + String name = attribute.getName(); + if (attribute.isParam()) { + Object value = ((ResourceBase) component).getProperty(name); + return value == null ? null : String.valueOf(value); + } + try { + if ("protocols".equals(name) && component instanceof SSLHostConfig hostConfig) { + StringBuilder protocols = new StringBuilder(); + for (String protocol : hostConfig.getProtocols()) { + if (!protocols.isEmpty()) { + protocols.append('+'); + } + protocols.append(protocol); + } + return protocols.toString(); + } + if ("certificateVerification".equals(name) && component instanceof SSLHostConfig hostConfig) { + return hostConfig.getCertificateVerificationAsString(); + } + try { + return component.getClass().getMethod("get" + capitalize(name)).invoke(component); + } catch (NoSuchMethodException nsm) { + return component.getClass().getMethod("is" + capitalize(name)).invoke(component); + } + } catch (Exception e) { + return null; + } + } + + + private static void setExplicitValue(Object component, ExplicitAttribute attribute, Object value) + throws ConfigException { + if (attribute.isParam()) { + // A string parameter of the generic property map of a JNDI + // entry; an empty value removes the parameter. + ResourceBase base = (ResourceBase) component; + if (value == null || (value instanceof String s && s.isEmpty())) { + base.removeProperty(attribute.getName()); + } else { + base.setProperty(attribute.getName(), String.valueOf(value)); + } + return; + } + String method = "set" + capitalize(attribute.getName()); + try { + Method m = component.getClass().getMethod(method, classForType(attribute.getType())); + m.invoke(component, value); + } catch (NoSuchMethodException e) { + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", + sm.getString("manager2.configNoSetter", method)); + } catch (InvocationTargetException e) { + Throwable t = e.getTargetException(); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", + sm.getString("manager2.configSetFailed", attribute.getName(), + t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage())); + } catch (IllegalAccessException e) { + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", + sm.getString("manager2.configNoSetter", method)); + } + } + + + /** + * Convert an arbitrary component value to a JSON safe representation (null, String, Number, Boolean, List or Map). + */ + private static Object jsonSafe(Object value) { + if (value == null) { + return null; + } + if (value instanceof String || value instanceof Boolean) { + return value; + } + if (value instanceof Number && !(value instanceof Double d && (d.isNaN() || d.isInfinite()))) { + return value; + } + if (value instanceof String[] array) { + return List.of(array); + } + if (value instanceof Object[] array) { + List list = new ArrayList<>(array.length); + for (Object item : array) { + list.add(String.valueOf(item)); + } + return list; + } + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + result.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + return result; + } + if (value instanceof Double d && (d.isNaN() || d.isInfinite())) { + return String.valueOf(value); + } + return String.valueOf(value); + } + + + // ---------------------------------------------------------- Resolving + + + /** + * A resolved tree node: the component, its parent (if it has one), the node type and, for alias nodes, the alias + * value. + */ + private static final class NodeRef { + + private final Object component; + private final Object parent; + private final String type; + private final String aliasValue; + private final String id; + + + NodeRef(Object component, Object parent, String type, String aliasValue, String id) { + this.component = component; + this.parent = parent; + this.type = type; + this.aliasValue = aliasValue; + this.id = id; + } + } + + + /** + * Resolve a node id (see the class javadoc for the grammar) to a component. + */ + private NodeRef resolve(String id) throws ConfigException { + + if (id == null || id.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + + String[] segments = id.split("/", -1); + if (!"server".equals(segments[0])) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + sm.getString("manager2.configNotFound")); + } + + Object current = server; + Object parent = null; + + for (int i = 1; i < segments.length; i += 2) { + if (i + 1 >= segments.length) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + sm.getString("manager2.configNotFound")); + } + String kind = segments[i]; + String value = dec(segments[i + 1]); + + switch (kind) { + case "service" -> { + Service service = server.findService(value); + if (service == null) { + throw notFound(); + } + parent = current; + current = service; + } + case "engine" -> { + if (!(current instanceof StandardService service) || + !(service.getContainer() instanceof Engine engine)) { + throw notFound(); + } + parent = current; + current = engine; + } + case "host" -> { + if (!(current instanceof StandardEngine engine) || + !(engine.findChild(value) instanceof Host host)) { + throw notFound(); + } + parent = current; + current = host; + } + case "context" -> { + // The root context is registered under the empty name, + // not "/", so map the bare "+" segment accordingly. + String contextName = "+".equals(segments[i + 1]) ? "" : value; + if (!(current instanceof Host host) || !(host.findChild(contextName) instanceof Context context)) { + throw notFound(); + } + parent = current; + current = context; + } + case "wrapper" -> { + if (!(current instanceof Context context) || + !(context.findChild(value) instanceof Wrapper wrapper)) { + throw notFound(); + } + parent = current; + current = wrapper; + } + case "alias" -> { + if (!(current instanceof Host host)) { + throw notFound(); + } + if (!Arrays.asList(host.findAliases()).contains(value)) { + throw notFound(); + } + return new NodeRef(host, host, "alias", value, id); + } + case "valve" -> { + if (!(current instanceof Container container)) { + throw notFound(); + } + Valve[] valves = container.getPipeline().getValves(); + int index = index(value); + if (index < 0 || index >= valves.length) { + throw notFound(); + } + parent = current; + current = valves[index]; + } + case "connector" -> { + if (!(current instanceof StandardService service)) { + throw notFound(); + } + Connector[] connectors = service.findConnectors(); + int idx = index(value); + if (idx < 0 || idx >= connectors.length) { + throw notFound(); + } + parent = current; + current = connectors[idx]; + } + case "executor" -> { + if (!(current instanceof StandardService service)) { + throw notFound(); + } + Executor found = null; + for (Executor executor : service.findExecutors()) { + if (executor.getName().equals(value)) { + found = executor; + break; + } + } + if (found == null) { + throw notFound(); + } + parent = current; + current = found; + } + case "listener" -> { + if (!(current instanceof LifecycleBase base)) { + throw notFound(); + } + LifecycleListener[] listeners = base.findLifecycleListeners(); + int index = index(value); + if (index < 0 || index >= listeners.length) { + throw notFound(); + } + parent = current; + current = listeners[index]; + } + case "realm" -> { + int index = index(value); + Realm found = null; + if (current instanceof Container container) { + // A container holds at most one directly attached + // realm, addressed as index 0. + if (index == 0) { + found = ownRealm(container); + } + } else if (current instanceof CombinedRealm combined) { + Realm[] nested = combined.getNestedRealms(); + if (index >= 0 && index < nested.length) { + found = nested[index]; + } + } + if (found == null) { + throw notFound(); + } + parent = current; + current = found; + } + case "sslHostConfig" -> { + if (!(current instanceof Connector connector)) { + throw notFound(); + } + SSLHostConfig[] hostConfigs = connector.findSslHostConfigs(); + SSLHostConfig found = null; + if (hostConfigs != null) { + for (SSLHostConfig hostConfig : hostConfigs) { + // Host names are case-insensitive and stored + // in lower case. + if (hostConfig.getHostName().equalsIgnoreCase(value)) { + found = hostConfig; + break; + } + } + } + if (found == null) { + throw notFound(); + } + parent = current; + current = found; + } + case "certificate" -> { + if (!(current instanceof SSLHostConfig hostConfig)) { + throw notFound(); + } + SSLHostConfigCertificate[] certificates = hostConfig.getCertificates() + .toArray(new SSLHostConfigCertificate[0]); + int index = index(value); + if (index < 0 || index >= certificates.length) { + throw notFound(); + } + parent = current; + current = certificates[index]; + } + case "manager" -> { + // A context holds exactly one manager, addressed as + // index 0. + if (!(current instanceof Context context) || index(value) != 0) { + throw notFound(); + } + Manager manager = context.getManager(); + if (manager == null) { + throw notFound(); + } + parent = current; + current = manager; + } + case "resources" -> { + if (!(current instanceof Context context) || index(value) != 0) { + throw notFound(); + } + WebResourceRoot resources = context.getResources(); + if (resources == null) { + throw notFound(); + } + parent = current; + current = resources; + } + case "loader" -> { + if (!(current instanceof Context context) || index(value) != 0) { + throw notFound(); + } + Loader loader = context.getLoader(); + if (loader == null) { + throw notFound(); + } + parent = current; + current = loader; + } + case "cookieProcessor" -> { + if (!(current instanceof Context context) || index(value) != 0) { + throw notFound(); + } + CookieProcessor cookieProcessor = context.getCookieProcessor(); + if (cookieProcessor == null) { + throw notFound(); + } + parent = current; + current = cookieProcessor; + } + case "sessionIdGenerator" -> { + // A manager holds at most one session id generator, + // addressed as index 0. + if (!(current instanceof Manager manager) || index(value) != 0) { + throw notFound(); + } + SessionIdGenerator generator = manager.getSessionIdGenerator(); + if (generator == null) { + throw notFound(); + } + parent = current; + current = generator; + } + case "namingResources" -> { + // The server and each context hold exactly one + // NamingResourcesImpl, addressed as index 0. + if (index(value) != 0) { + throw notFound(); + } + NamingResourcesImpl namingResources = null; + if (current instanceof StandardServer s) { + namingResources = s.getGlobalNamingResources(); + } else if (current instanceof Context context) { + namingResources = context.getNamingResources(); + } + if (namingResources == null) { + throw notFound(); + } + parent = current; + current = namingResources; + } + case "resource" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findResource(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findResource(value); + } + case "resourceLink" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findResourceLink(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findResourceLink(value); + } + case "resourceEnvRef" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findResourceEnvRef(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findResourceEnvRef(value); + } + case "environment" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findEnvironment(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findEnvironment(value); + } + case "ejb" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findEjb(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findEjb(value); + } + case "localEjb" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findLocalEjb(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findLocalEjb(value); + } + case "serviceRef" -> { + if (!(current instanceof NamingResourcesImpl namingResources) || + namingResources.findService(value) == null) { + throw notFound(); + } + parent = current; + current = namingResources.findService(value); + } + case "cluster" -> { + if (!(current instanceof Container container) || ownCluster(container) == null) { + throw notFound(); + } + parent = current; + current = container.getCluster(); + } + case "channel" -> { + if (!(current instanceof CatalinaCluster cluster) || index(value) != 0) { + throw notFound(); + } + Channel channel = cluster.getChannel(); + if (channel == null) { + throw notFound(); + } + parent = current; + current = channel; + } + case "membership" -> { + if (!(current instanceof ManagedChannel managed) || index(value) != 0) { + throw notFound(); + } + MembershipService membership = managed.getMembershipService(); + if (membership == null) { + throw notFound(); + } + parent = current; + current = membership; + } + case "sender" -> { + if (!(current instanceof ManagedChannel managed) || index(value) != 0) { + throw notFound(); + } + ChannelSender sender = managed.getChannelSender(); + if (sender == null) { + throw notFound(); + } + parent = current; + current = sender; + } + case "transport" -> { + if (!(current instanceof ReplicationTransmitter transmitter) || index(value) != 0) { + throw notFound(); + } + MultiPointSender transport = transmitter.getTransport(); + if (transport == null) { + throw notFound(); + } + parent = current; + current = transport; + } + case "receiver" -> { + if (!(current instanceof ManagedChannel managed) || index(value) != 0) { + throw notFound(); + } + ChannelReceiver receiver = managed.getChannelReceiver(); + if (receiver == null) { + throw notFound(); + } + parent = current; + current = receiver; + } + case "interceptor" -> { + if (!(current instanceof ManagedChannel managed)) { + throw notFound(); + } + int wantedInterceptor = index(value); + Iterator interceptorIter = managed.getInterceptors(); + int interceptorIndex = 0; + ChannelInterceptor foundInterceptor = null; + while (interceptorIter.hasNext()) { + ChannelInterceptor interceptor = interceptorIter.next(); + if (interceptorIndex == wantedInterceptor) { + foundInterceptor = interceptor; + break; + } + interceptorIndex++; + } + if (foundInterceptor == null) { + throw notFound(); + } + parent = current; + current = foundInterceptor; + } + case "clusterValve" -> { + if (!(current instanceof CatalinaCluster cluster)) { + throw notFound(); + } + Valve[] clusterValves = cluster.getValves(); + int valveIndex = index(value); + if (valveIndex < 0 || valveIndex >= clusterValves.length) { + throw notFound(); + } + parent = current; + current = clusterValves[valveIndex]; + } + case "deployer" -> { + if (!(current instanceof CatalinaCluster cluster) || index(value) != 0) { + throw notFound(); + } + ClusterDeployer deployer = cluster.getClusterDeployer(); + if (deployer == null) { + throw notFound(); + } + parent = current; + current = deployer; + } + case "clusterManager" -> { + if (!(current instanceof SimpleTcpCluster tcp) || index(value) != 0) { + throw notFound(); + } + ClusterManager manager = tcp.getManagerTemplate(); + if (manager == null) { + throw notFound(); + } + parent = current; + current = manager; + } + case "clusterListener" -> { + if (!(current instanceof SimpleTcpCluster tcp)) { + throw notFound(); + } + int wantedListener = index(value); + int listenerIndex = 0; + ClusterListener foundListener = null; + for (ClusterListener listener : tcp.findClusterListeners()) { + if (listener == tcp.getClusterDeployer()) { + continue; + } + if (listenerIndex == wantedListener) { + foundListener = listener; + break; + } + listenerIndex++; + } + if (foundListener == null) { + throw notFound(); + } + parent = current; + current = foundListener; + } + case "localMember" -> { + Member foundLocalMember = null; + if (current instanceof StaticMembershipService sms) { + foundLocalMember = sms.getLocalMember(false); + } else if (current instanceof StaticMembershipInterceptor smi) { + foundLocalMember = smi.getLocalMember(false); + } + if (foundLocalMember == null) { + throw notFound(); + } + parent = current; + current = foundLocalMember; + } + case "member" -> { + if (!(current instanceof StaticMembershipService sms)) { + throw notFound(); + } + List staticMembers = sms.getStaticMembers(); + int memberIndex = index(value); + if (memberIndex < 0 || memberIndex >= staticMembers.size()) { + throw notFound(); + } + parent = current; + current = staticMembers.get(memberIndex); + } + default -> throw notFound(); + } + } + + String type; + if (current instanceof Server) { + type = "server"; + } else if (current instanceof Service) { + type = "service"; + } else if (current instanceof Engine) { + type = "engine"; + } else if (current instanceof Host) { + type = "host"; + } else if (current instanceof Context) { + type = "context"; + } else if (current instanceof Wrapper) { + type = "wrapper"; + } else if (current instanceof Connector) { + type = "connector"; + } else if (current instanceof Executor) { + type = "executor"; + } else if (current instanceof Realm) { + type = "realm"; + } else if (current instanceof ClusterManager) { + type = "clusterManager"; + } else if (current instanceof Manager) { + type = "manager"; + } else if (current instanceof CatalinaCluster) { + type = "cluster"; + } else if (current instanceof Channel) { + type = "channel"; + } else if (current instanceof MembershipService) { + type = "membership"; + } else if (current instanceof ChannelSender) { + type = "sender"; + } else if (current instanceof MultiPointSender) { + type = "transport"; + } else if (current instanceof ChannelReceiver) { + type = "receiver"; + } else if (current instanceof ClusterDeployer) { + type = "deployer"; + } else if (current instanceof ClusterValve) { + type = "clusterValve"; + } else if (current instanceof ClusterListener) { + type = "clusterListener"; + } else if (current instanceof Member) { + type = "member"; + } else if (current instanceof ChannelInterceptor) { + type = "interceptor"; + } else if (current instanceof SessionIdGenerator) { + type = "sessionIdGenerator"; + } else if (current instanceof WebResourceRoot) { + type = "resources"; + } else if (current instanceof Loader) { + type = "loader"; + } else if (current instanceof CookieProcessor) { + type = "cookieProcessor"; + } else if (current instanceof NamingResourcesImpl) { + type = "namingResources"; + } else if (current instanceof ContextResource) { + type = "resource"; + } else if (current instanceof ContextResourceLink) { + type = "resourceLink"; + } else if (current instanceof ContextResourceEnvRef) { + type = "resourceEnvRef"; + } else if (current instanceof ContextEnvironment) { + type = "environment"; + } else if (current instanceof ContextEjb) { + type = "ejb"; + } else if (current instanceof ContextLocalEjb) { + type = "localEjb"; + } else if (current instanceof ContextService) { + type = "serviceRef"; + } else if (current instanceof SSLHostConfig) { + type = "sslHostConfig"; + } else if (current instanceof SSLHostConfigCertificate) { + type = "certificate"; + } else if (current instanceof LifecycleListener) { + type = "listener"; + } else { + type = "valve"; + } + return new NodeRef(current, parent, type, null, id); + } + + + private static ConfigException notFound() { + return new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + sm.getString("manager2.configNotFound")); + } + + + private static int index(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return -1; + } + } + + + // ------------------------------------------------- Attribute updating + + + private void updateAttribute(HttpServletResponse response, Map body) throws Exception { + + String id = string(body.get("id")); + String name = string(body.get("name")); + if (id == null || name == null || name.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + + NodeRef ref = resolve(id); + if (!"server".equals(ref.type) && ref.component == selfContext && + ("name".equals(name) || "path".equals(name))) { + throw new ConfigException(HttpServletResponse.SC_FORBIDDEN, "SELF_COMPONENT", + sm.getString("manager2.configSelfComponent")); + } + + // Components without a modeler descriptor take their attributes + // from the explicit list. + if (!explicitAttributes(ref.component, ref.type).isEmpty()) { + updateExplicitAttribute(response, ref, name, body); + return; + } + + ManagedBean descriptor = descriptor(ref.component); + AttributeInfo attribute = findAttribute(descriptor, name); + if (attribute == null) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND", + sm.getString("manager2.configAttributeNotFound", name)); + } + if (!attribute.isWriteable() || !EDITABLE_TYPES.contains(attribute.getType())) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", + sm.getString("manager2.configReadOnly", name)); + } + + if (RISKY_ATTRIBUTES.contains(name)) { + String confirm = string(body.get("confirm")); + // For a context the display name is the (possibly normalized) + // context path, which is also what the UI asks to be confirmed. + String expected = displayName(ref.component, ref.type); + if (!expected.equals(confirm)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", + sm.getString("manager2.configConfirmRequired", expected)); + } + } + + Object value = convert(attribute.getType(), body.get("value")); + setValue(ref.component, attribute, value); + + log(sm.getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + String.valueOf(value))); + Api.ok(response, sm.getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); + } + + + /** + * Update one attribute of a component without a modeler descriptor (the {@code sslHostConfig} and + * {@code certificate} node types, and the {@code loader}, {@code cookieProcessor} and {@code sessionIdGenerator} + * node types of the standard implementations). + */ + private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, String name, + Map body) throws Exception { + + Object component = ref.component; + boolean namingEntry = isNamingEntry(ref.type); + ResourceBase entry = namingEntry ? (ResourceBase) component : null; + String oldEntryName = entry == null ? null : entry.getName(); + + // Renaming a JNDI entry changes its JNDI name; require the same + // type-to-confirm as the other renames. + if (namingEntry && RISKY_ATTRIBUTES.contains(name)) { + String confirm = string(body.get("confirm")); + String expected = displayName(component, ref.type); + if (!expected.equals(confirm)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", + sm.getString("manager2.configConfirmRequired", expected)); + } + } + + ExplicitAttribute attribute = findExplicitAttribute(component, ref.type, name); + boolean param = false; + String oldParamValue = null; + if (attribute == null) { + // A free form string parameter of a JNDI entry: any parameter + // name is accepted, the JNDI factory decides which ones it + // consumes. + if (!namingEntry) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND", + sm.getString("manager2.configAttributeNotFound", name)); + } + param = true; + Object current = entry.getProperty(name); + oldParamValue = current == null ? null : String.valueOf(current); + attribute = new ExplicitAttribute(name, "java.lang.String", true, null, true); + } else if (!attribute.isWritable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", + sm.getString("manager2.configReadOnly", name)); + } + + Object value = param ? string(body.get("value")) : convert(attribute.getType(), body.get("value")); + Object oldValue = param ? null : readExplicitValue(component, attribute); + setExplicitValue(component, attribute, value); + + // A JNDI entry update is only visible to the live JNDI + // environment (the NamingContextListener reacts to the property + // change events) when the entry is removed and re-added. + if (namingEntry) { + try { + rebindEntry(ref, oldEntryName); + } catch (Exception e) { + // Roll the change back and re-register the entry as it + // was before the update. + try { + if (param) { + if (oldParamValue == null) { + entry.removeProperty(name); + } else { + entry.setProperty(name, oldParamValue); + } + } else { + setExplicitValue(component, attribute, oldValue); + } + entry.setName(oldEntryName); + rebindEntry(ref, oldEntryName); + } catch (Exception rollbackError) { + log(sm.getString("manager2.error.config"), rollbackError); + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", + sm.getString("manager2.configSetFailed", name, rootMessage(e))); + } + } + + // On a running, TLS enabled connector the change is applied (and + // validated) at once by re-creating the SSL context of the + // affected host configuration. When the new value does not + // validate the previous value is restored. + SSLHostConfig sslHostConfig = null; + Connector connector = null; + if ("sslHostConfig".equals(ref.type)) { + sslHostConfig = (SSLHostConfig) ref.component; + if (ref.parent instanceof Connector parent) { + connector = parent; + } + } else if ("certificate".equals(ref.type) && ref.parent instanceof SSLHostConfig parent) { + sslHostConfig = parent; + connector = connectorOfSsl(sslHostConfig); + } + if (connector != null && connector.getState().isAvailable() && isSslEnabled(connector)) { + try { + endpointOf(connector).reloadSslHostConfig(sslHostConfig.getHostName()); + } catch (Exception e) { + setExplicitValue(ref.component, attribute, oldValue); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", sm.getString( + "manager2.configSslReloadFailed", name, displayName(ref.component, ref.type), rootMessage(e))); + } + } + + log(sm.getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + String.valueOf(value))); + Api.ok(response, sm.getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); + } + + + /** + * Re-register a JNDI entry whose state has changed in place: remove it (by the name it was registered under) and + * add it again. The remove and add fire the property change events that the {@code NamingContextListener} uses to + * update the live JNDI environment. + */ + private void rebindEntry(NodeRef ref, String registeredName) throws Exception { + NamingResourcesImpl namingResources = (NamingResourcesImpl) ref.parent; + ResourceBase entry = (ResourceBase) ref.component; + String newName = entry.getName(); + boolean renamed = !registeredName.equals(newName); + if (renamed) { + // The removal event carries the entry and the NamingContextListener + // unbinds the JNDI name the entry reports. Restore the name the + // entry was registered under so the old binding (not the new one) + // is the one that is removed. + entry.setName(registeredName); + } + try { + removeNamingEntry(namingResources, ref.type, registeredName); + } finally { + if (renamed) { + entry.setName(newName); + } + } + addNamingEntryTo(namingResources, ref.type, entry); + // The add is a silent no-op when the (new) name is already used + // by another entry; detect that so the caller can roll back. + if (findNamingEntry(namingResources, ref.type, newName) != entry) { + throw new IllegalStateException(sm.getString("manager2.configJndiNameTaken", newName)); + } + } + + + private static ResourceBase findNamingEntry(NamingResourcesImpl namingResources, String type, String name) { + return switch (type) { + case "resource" -> namingResources.findResource(name); + case "resourceLink" -> namingResources.findResourceLink(name); + case "resourceEnvRef" -> namingResources.findResourceEnvRef(name); + case "environment" -> namingResources.findEnvironment(name); + case "ejb" -> namingResources.findEjb(name); + case "localEjb" -> namingResources.findLocalEjb(name); + case "serviceRef" -> namingResources.findService(name); + default -> null; + }; + } + + + private static void removeNamingEntry(NamingResourcesImpl namingResources, String type, String name) { + switch (type) { + case "resource" -> namingResources.removeResource(name); + case "resourceLink" -> namingResources.removeResourceLink(name); + case "resourceEnvRef" -> namingResources.removeResourceEnvRef(name); + case "environment" -> namingResources.removeEnvironment(name); + case "ejb" -> namingResources.removeEjb(name); + case "localEjb" -> namingResources.removeLocalEjb(name); + case "serviceRef" -> namingResources.removeService(name); + default -> { + } + } + } + + + private static void addNamingEntryTo(NamingResourcesImpl namingResources, String type, ResourceBase entry) { + switch (type) { + case "resource" -> namingResources.addResource((ContextResource) entry); + case "resourceLink" -> namingResources.addResourceLink((ContextResourceLink) entry); + case "resourceEnvRef" -> namingResources.addResourceEnvRef((ContextResourceEnvRef) entry); + case "environment" -> namingResources.addEnvironment((ContextEnvironment) entry); + case "ejb" -> namingResources.addEjb((ContextEjb) entry); + case "localEjb" -> namingResources.addLocalEjb((ContextLocalEjb) entry); + case "serviceRef" -> namingResources.addService((ContextService) entry); + default -> { + } + } + } + + + /** + * Convert a JSON value to the Java type of the attribute. + */ + private static Object convert(String type, Object json) throws ConfigException { + String message = sm.getString("manager2.configInvalidValue", type); + try { + switch (type) { + case "boolean": + if (json instanceof Boolean b) { + return b; + } + return Boolean.valueOf(Boolean.parseBoolean(String.valueOf(json))); + case "int": + return Integer.valueOf(Integer.parseInt(String.valueOf(json).trim())); + case "long": + return Long.valueOf(Long.parseLong(String.valueOf(json).trim())); + case "short": + return Short.valueOf(Short.parseShort(String.valueOf(json).trim())); + case "byte": + return Byte.valueOf(Byte.parseByte(String.valueOf(json).trim())); + case "float": + return Float.valueOf(Float.parseFloat(String.valueOf(json).trim())); + case "double": + return Double.valueOf(Double.parseDouble(String.valueOf(json).trim())); + case "java.lang.String": + return String.valueOf(json); + case "[Ljava.lang.String;": + if (json instanceof List list) { + String[] result = new String[list.size()]; + for (int i = 0; i < list.size(); i++) { + result[i] = String.valueOf(list.get(i)); + } + return result; + } + return String.valueOf(json).split(","); + default: + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UNSUPPORTED_TYPE", message); + } + } catch (NumberFormatException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", message); + } + } + + + private static void setValue(Object component, AttributeInfo attribute, Object value) throws ConfigException { + + String method = attribute.getSetMethod(); + if (method == null) { + method = "set" + capitalize(attribute.getName()); + } + try { + Method m = component.getClass().getMethod(method, classForType(attribute.getType())); + m.invoke(component, value); + } catch (NoSuchMethodException e) { + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", + sm.getString("manager2.configNoSetter", method)); + } catch (InvocationTargetException e) { + Throwable t = e.getTargetException(); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", + sm.getString("manager2.configSetFailed", attribute.getName(), + t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage())); + } catch (IllegalAccessException e) { + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", + sm.getString("manager2.configNoSetter", method)); + } + } + + + private static Class classForType(String type) { + switch (type) { + case "boolean": + return boolean.class; + case "int": + return int.class; + case "long": + return long.class; + case "short": + return short.class; + case "byte": + return byte.class; + case "float": + return float.class; + case "double": + return double.class; + case "java.lang.String": + return String.class; + case "[Ljava.lang.String;": + return String[].class; + default: + try { + return Class.forName(type); + } catch (ClassNotFoundException e) { + return Object.class; + } + } + } + + + // ----------------------------------------------------- Structural ops + + + /** + * Register a new component (the {@code add} operation) and verify that it actually started. The register operations + * of the core components report a failed start inconsistently: some throw, some log and leave a failed component + * behind, the valve pipeline even cleans up silently. All cases are handled here: on a failed start the + * {@code undo} operation is attempted and a controlled error is raised, so that no broken component is left + * registered. + */ + private void addChecked(String label, Object component, Runnable add, Runnable undo) throws ConfigException { + try { + add.run(); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + rollback(label, undo); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", label, rootMessage(e))); + } + if (component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { + rollback(label, undo); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", label, "the component did not start")); + } + } + + + private void rollback(String label, Runnable undo) { + try { + undo.run(); + } catch (Exception e) { + log(sm.getString("manager2.configRollbackFailed", label, + e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), e); + } + } + + + /** + * The message of the deepest cause of the given exception. + */ + private static String rootMessage(Throwable t) { + Throwable current = t; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + String message = current.getMessage(); + return (message == null || message.isEmpty()) ? current.getClass().getSimpleName() : message; + } + + + private void addChild(HttpServletResponse response, Map body) throws Exception { + + String parentId = string(body.get("parent")); + String type = string(body.get("type")); + if (parentId == null || type == null) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + NodeRef parent = resolve(parentId); + + // The only child of an SSL host configuration is a certificate, + // and in its request body the "type" field carries the + // certificate type (RSA, DSA, ...), not a child component type. + if (parent.component instanceof SSLHostConfig) { + addCertificate(response, parent, body); + return; + } + + switch (type) { + case "service" -> addService(response, parent, body); + case "host" -> addHost(response, parent, body); + case "context" -> addContext(response, parent, body); + case "wrapper" -> addWrapper(response, parent, body); + case "valve" -> addValve(response, parent, body); + case "connector" -> addConnector(response, parent, body); + case "executor" -> addExecutor(response, parent, body); + case "alias" -> addAlias(response, parent, body); + case "listener" -> addListener(response, parent, body); + case "realm" -> addRealm(response, parent, body); + case "manager" -> addContextComponent(response, parent, body, "manager"); + case "resources" -> addContextComponent(response, parent, body, "resources"); + case "loader" -> addContextComponent(response, parent, body, "loader"); + case "cookieProcessor" -> addContextComponent(response, parent, body, "cookieProcessor"); + case "sessionIdGenerator" -> addSessionIdGenerator(response, parent, body); + case "resource" -> addNamingEntry(response, parent, body, "resource"); + case "resourceLink" -> addNamingEntry(response, parent, body, "resourceLink"); + case "resourceEnvRef" -> addNamingEntry(response, parent, body, "resourceEnvRef"); + case "environment" -> addNamingEntry(response, parent, body, "environment"); + case "ejb" -> addNamingEntry(response, parent, body, "ejb"); + case "localEjb" -> addNamingEntry(response, parent, body, "localEjb"); + case "serviceRef" -> addNamingEntry(response, parent, body, "serviceRef"); + case "sslHostConfig" -> addSslHostConfig(response, parent, body); + case "certificate" -> addCertificate(response, parent, body); + case "cluster" -> addCluster(response, parent, body); + case "clusterValve" -> addClusterValve(response, parent, body); + case "channel" -> addClusterChannel(response, parent, body); + case "membership" -> addClusterMembership(response, parent, body); + case "sender" -> addClusterSender(response, parent, body); + case "receiver" -> addClusterReceiver(response, parent, body); + case "interceptor" -> addInterceptor(response, parent, body); + case "deployer" -> addClusterDeployer(response, parent, body); + case "clusterManager" -> addClusterManager(response, parent, body); + case "transport" -> addClusterTransport(response, parent, body); + case "clusterListener" -> addClusterListener(response, parent, body); + default -> throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UNSUPPORTED_TYPE", + sm.getString("manager2.configTypeUnsupported", type)); + } + } + + + private void addAlias(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof Host host)) { + throw badParent("alias"); + } + String alias = string(body.get("alias")); + if (alias == null || alias.isEmpty() || alias.contains(" ") || alias.contains(",")) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", alias)); + } + if (Arrays.asList(host.findAliases()).contains(alias)) { + throw duplicate(alias); + } + host.addAlias(alias); + log(sm.getString("manager2.configAuditAdd", "alias", alias)); + Api.ok(response, sm.getString("manager2.configAdded", alias)); + } + + + /** + * A service in this Tomcat version holds exactly one engine (its {@code container}), so adding a service creates + * the service together with a new engine of the same name. + */ + private void addService(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof Server)) { + throw badParent("service"); + } + String name = requiredName(body.get("name")); + if (server.findService(name) != null) { + throw duplicate(name); + } + StandardService service = new StandardService(); + service.setName(name); + StandardEngine engine = new StandardEngine(); + engine.setName(name); + engine.setRealm(new MemoryRealm()); + service.setContainer(engine); + Server serverComponent = (Server) parent.component; + addChecked(name, service, () -> serverComponent.addService(service), + () -> serverComponent.removeService(service)); + log(sm.getString("manager2.configAuditAdd", "service", name)); + Api.ok(response, sm.getString("manager2.configAdded", name)); + } + + + private void addHost(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof StandardEngine engine)) { + throw badParent("host"); + } + String name = requiredName(body.get("name")); + if (engine.findChild(name) instanceof Host) { + throw duplicate(name); + } + + String appBase = string(body.get("appBase")); + if (appBase == null || appBase.isEmpty()) { + appBase = name; + } + File appBaseFile = new File(appBase); + if (!appBaseFile.isAbsolute()) { + appBaseFile = new File(engine.getCatalinaBase(), appBaseFile.getPath()); + } + appBaseFile = appBaseFile.getCanonicalFile(); + if (!appBaseFile.getPath().startsWith(engine.getCatalinaBase().getPath() + File.separator) && + !appBaseFile.getPath().equals(engine.getCatalinaBase().getPath())) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", + sm.getString("manager2.configInvalidPath", appBase)); + } + if (!appBaseFile.mkdirs() && !appBaseFile.isDirectory()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", + sm.getString("manager2.configInvalidPath", appBase)); + } + + StandardHost host = new StandardHost(); + host.setName(name); + host.setAppBase(appBase); + host.addLifecycleListener(new HostConfig()); + for (String alias : stringList(body.get("aliases"))) { + host.addAlias(alias); + } + host.setAutoDeploy(bool(body.get("autoDeploy"), true)); + host.setDeployOnStartup(bool(body.get("deployOnStartup"), true)); + host.setDeployXML(bool(body.get("deployXML"), true)); + host.setUnpackWARs(bool(body.get("unpackWARs"), true)); + host.setCopyXML(bool(body.get("copyXML"), false)); + addChecked(name, host, () -> engine.addChild(host), () -> engine.removeChild(host)); + log(sm.getString("manager2.configAuditAdd", "host", name)); + Api.ok(response, sm.getString("manager2.configAdded", name)); + } + + + private void addContext(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof Host)) { + throw badParent("context"); + } + Host host = (Host) parent.component; + String path = string(body.get("path")); + if (path == null || !path.startsWith("/") || path.length() < 2 || path.contains("..") || path.contains(" ")) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", + sm.getString("manager2.configInvalidPath", path)); + } + if (host.findChild(path) instanceof Context) { + throw duplicate(path); + } + + StandardContext context = new StandardContext(); + context.setPath(path); + context.setName(path); + // A docBase is interpreted exactly like in server.xml: a relative + // value is resolved against the appBase of the host. The default + // (as for HostConfig) is the context path below the appBase. + String docBase = string(body.get("docBase")); + if (docBase != null && !docBase.isEmpty()) { + context.setDocBase(docBase); + } else { + context.setDocBase(path.length() > 1 ? path.substring(1) : "ROOT"); + } + context.setParent(host); + File docBaseFile = contextDocBaseFile(context); + if (docBaseFile != null && !docBaseFile.getName().endsWith(".war") && !docBaseFile.isDirectory() && + !docBaseFile.mkdirs()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", + sm.getString("manager2.configDocBaseMissing", docBaseFile.getPath())); + } + String displayName = string(body.get("displayName")); + if (displayName != null && !displayName.isEmpty()) { + context.setDisplayName(displayName); + } + context.addLifecycleListener(new ContextConfig()); + addChecked(path, context, () -> host.addChild(context), () -> host.removeChild(context)); + log(sm.getString("manager2.configAuditAdd", "context", path)); + Api.ok(response, sm.getString("manager2.configAdded", path)); + } + + + /** + * The resolved location of the context document base (a relative {@code docBase} is resolved against the appBase of + * the host). + */ + private static File contextDocBaseFile(StandardContext context) { + File file = new File(context.getDocBase()); + if (!file.isAbsolute() && context.getParent() instanceof Host host) { + file = new File(host.getAppBaseFile(), file.getPath()); + } + return file; + } + + + private void addWrapper(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof Context)) { + throw badParent("wrapper"); + } + Context context = (Context) parent.component; + String servletClass = string(body.get("servletClass")); + if (servletClass == null || servletClass.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "servletClass")); + } + String name = string(body.get("name")); + if (name == null || name.isEmpty()) { + name = servletClass; + } + if (!SAFE_NAME.matcher(name).matches()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", name)); + } + if (context.findChild(name) instanceof Wrapper) { + throw duplicate(name); + } + + StandardWrapper wrapper = new StandardWrapper(); + wrapper.setName(name); + wrapper.setServletClass(servletClass); + wrapper.setLoadOnStartup(intValue(body.get("loadOnStartup"), 0)); + addChecked(name, wrapper, () -> context.addChild(wrapper), () -> context.removeChild(wrapper)); + for (String pattern : stringList(body.get("urlPatterns"))) { + context.addServletMapping(pattern, name); + } + log(sm.getString("manager2.configAuditAdd", "wrapper", name)); + Api.ok(response, sm.getString("manager2.configAdded", name)); + } + + + private void addValve(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof Container)) { + throw badParent("valve"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + Valve valve; + try { + // Valves are server level classes: never use the webapp class + // loader. + valve = (Valve) Class.forName(className, true, server.getClass().getClassLoader()).getConstructor() + .newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + Pipeline pipeline = ((Container) parent.component).getPipeline(); + addChecked(className, valve, () -> pipeline.addValve(valve), () -> pipeline.removeValve(valve)); + log(sm.getString("manager2.configAuditAdd", "valve", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + private void addListener(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + // Every component that implements Lifecycle accepts a lifecycle + // listener (containers, valves, connectors, executors, ...). An + // alias is excluded: it is just a name on the host, not a + // component that holds listeners. + if ("alias".equals(parent.type) || !(parent.component instanceof Lifecycle lifecycle)) { + throw badParent("listener"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + LifecycleListener listener; + try { + // Listeners are server level classes: never use the webapp + // class loader. + listener = (LifecycleListener) Class.forName(className, true, server.getClass().getClassLoader()) + .getConstructor().newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + // A listener is registered, not started: the parent never drives + // its lifecycle (a listener that is itself a Lifecycle, such as a + // valve used as a listener, legitimately stays not started). + try { + lifecycle.addLifecycleListener(listener); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(sm.getString("manager2.configAuditAdd", "listener", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + // ------------------------------------------- Cluster components + + + /** + * Instantiate a cluster component (a class from the clustering jars) using the server class loader, or a sensible + * default when no class name is given. + */ + private T newClusterComponent(String className, String defaultClassName, Class type) throws ConfigException { + String name = (className == null || className.isEmpty()) ? defaultClassName : className; + try { + return type + .cast(Class.forName(name, true, server.getClass().getClassLoader()).getConstructor().newInstance()); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", name)); + } + } + + + /** + * Add a cluster to a container (engine, host or context). The cluster is a lifecycle component: when the container + * is running, {@code setCluster} starts it (which starts the channel and applies the cluster defaults), so the + * start is verified and rolled back on failure. + */ + private void addCluster(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + if (!(parent.component instanceof Container container)) { + throw badParent("cluster"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? SimpleTcpCluster.class.getName() : className; + CatalinaCluster cluster = newClusterComponent(className, SimpleTcpCluster.class.getName(), + CatalinaCluster.class); + addChecked(label, cluster, () -> container.setCluster(cluster), () -> container.setCluster(null)); + log(sm.getString("manager2.configAuditAdd", "cluster", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Add a cluster valve (a {@code Valve} that implements {@code ClusterValve}) to the cluster. + */ + private void addClusterValve(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof CatalinaCluster cluster)) { + throw badParent("clusterValve"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + Valve valve; + try { + valve = (Valve) Class.forName(className, true, server.getClass().getClassLoader()).getConstructor() + .newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + if (!(valve instanceof ClusterValve)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + try { + cluster.addValve(valve); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(sm.getString("manager2.configAuditAdd", "clusterValve", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + /** + * Replace the channel of the cluster. + */ + private void addClusterChannel(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof CatalinaCluster cluster)) { + throw badParent("channel"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? GroupChannel.class.getName() : className; + Channel channel = newClusterComponent(className, GroupChannel.class.getName(), Channel.class); + Channel previous = cluster.getChannel(); + try { + cluster.setChannel(channel); + } catch (Exception e) { + cluster.setChannel(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "channel", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Replace the membership service of the channel. + */ + private void addClusterMembership(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof ManagedChannel managed)) { + throw badParent("membership"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? McastService.class.getName() : className; + MembershipService membership = newClusterComponent(className, McastService.class.getName(), + MembershipService.class); + MembershipService previous = managed.getMembershipService(); + try { + managed.setMembershipService(membership); + } catch (Exception e) { + managed.setMembershipService(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "membership", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Replace the sender of the channel. + */ + private void addClusterSender(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof ManagedChannel managed)) { + throw badParent("sender"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? ReplicationTransmitter.class.getName() : className; + ChannelSender sender = newClusterComponent(className, ReplicationTransmitter.class.getName(), + ChannelSender.class); + ChannelSender previous = managed.getChannelSender(); + try { + managed.setChannelSender(sender); + } catch (Exception e) { + managed.setChannelSender(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "sender", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Replace the receiver of the channel. + */ + private void addClusterReceiver(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof ManagedChannel managed)) { + throw badParent("receiver"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) + ? "org.apache.catalina.tribes.transport.nio.NioReceiver" + : className; + ChannelReceiver receiver = newClusterComponent(className, + "org.apache.catalina.tribes.transport.nio.NioReceiver", ChannelReceiver.class); + ChannelReceiver previous = managed.getChannelReceiver(); + try { + managed.setChannelReceiver(receiver); + } catch (Exception e) { + managed.setChannelReceiver(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "receiver", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Add an interceptor to the channel. + */ + private void addInterceptor(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof ManagedChannel managed)) { + throw badParent("interceptor"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + ChannelInterceptor interceptor = newClusterComponent(className, null, ChannelInterceptor.class); + try { + managed.addInterceptor(interceptor); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw addFailed(className, e); + } + log(sm.getString("manager2.configAuditAdd", "interceptor", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + /** + * Replace the deployer of the cluster. + */ + private void addClusterDeployer(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof CatalinaCluster cluster)) { + throw badParent("deployer"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? "org.apache.catalina.ha.deploy.FarmWarDeployer" + : className; + ClusterDeployer deployer = newClusterComponent(className, "org.apache.catalina.ha.deploy.FarmWarDeployer", + ClusterDeployer.class); + ClusterDeployer previous = cluster.getClusterDeployer(); + try { + cluster.setClusterDeployer(deployer); + } catch (Exception e) { + cluster.setClusterDeployer(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "deployer", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Replace the manager template of the cluster. + */ + private void addClusterManager(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof SimpleTcpCluster tcp)) { + throw badParent("clusterManager"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) ? "org.apache.catalina.ha.session.DeltaManager" + : className; + ClusterManager manager = newClusterComponent(className, "org.apache.catalina.ha.session.DeltaManager", + ClusterManager.class); + ClusterManager previous = tcp.getManagerTemplate(); + try { + tcp.setManagerTemplate(manager); + } catch (Exception e) { + tcp.setManagerTemplate(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "clusterManager", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Replace the transport of the sender (a replication transmitter). + */ + private void addClusterTransport(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof ReplicationTransmitter transmitter)) { + throw badParent("transport"); + } + String className = string(body.get("className")); + String label = (className == null || className.isEmpty()) + ? "org.apache.catalina.tribes.transport.nio.PooledParallelSender" + : className; + MultiPointSender transport = newClusterComponent(className, + "org.apache.catalina.tribes.transport.nio.PooledParallelSender", MultiPointSender.class); + MultiPointSender previous = transmitter.getTransport(); + try { + transmitter.setTransport(transport); + } catch (Exception e) { + transmitter.setTransport(previous); + throw addFailed(label, e); + } + log(sm.getString("manager2.configAuditAdd", "transport", label)); + Api.ok(response, sm.getString("manager2.configAdded", label)); + } + + + /** + * Add a cluster listener to the cluster. + */ + private void addClusterListener(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + if (!(parent.component instanceof CatalinaCluster cluster)) { + throw badParent("clusterListener"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + ClusterListener listener = newClusterComponent(className, null, ClusterListener.class); + try { + cluster.addClusterListener(listener); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw addFailed(className, e); + } + log(sm.getString("manager2.configAuditAdd", "clusterListener", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + private static ConfigException addFailed(String label, Exception e) { + return new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", label, rootMessage(e))); + } + + + /** + * Add a realm: to a container (the container's own realm) or to a combined realm (a sub realm). The realm class is + * a server level class and is identified by its class name. + */ + private void addRealm(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + Realm realm; + try { + // Realms are server level classes: never use the webapp class + // loader. + realm = (Realm) Class.forName(className, true, server.getClass().getClassLoader()).getConstructor() + .newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + + if (parent.component instanceof Container container) { + addContainerRealm(response, container, realm, className); + } else if (parent.component instanceof CombinedRealm combined) { + addSubRealm(response, combined, realm, className); + } else { + throw badParent("realm"); + } + } + + + private void addContainerRealm(HttpServletResponse response, Container container, Realm realm, String className) + throws Exception { + + if (ownRealm(container) != null) { + throw duplicate(className); + } + Realm oldRealm = container.getRealm(); + boolean running = container.getState().isAvailable(); + try { + // setRealm wires the realm to the container and, on a running + // container, starts it. + container.setRealm(realm); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + // A realm that does not start on a running container is not + // useful; roll the change back. On a stopped container the realm + // is started when the container starts, so no check is needed. + if (running && realm instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { + container.setRealm(oldRealm); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", className, "the component did not start")); + } + log(sm.getString("manager2.configAuditAdd", "realm", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + private void addSubRealm(HttpServletResponse response, CombinedRealm combined, Realm realm, String className) + throws Exception { + + // The XML parser accepts at most this many nested Realm elements; + // keep runtime additions within the same bound so that the state + // still round trips through server.xml. + if (nestedRealmDepth(combined) >= MAX_NESTED_REALM_LEVELS) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configMaxNestedRealms")); + } + boolean running = combined.getState().isAvailable(); + try { + combined.addRealm(realm); + // Mirror what the XML flow achieves when the container + // attaches the combined realm: give the sub realm a container + // (for logging) and a distinct realm path (for JMX naming), + // and start it while the combined realm is running (the + // combined realm only starts sub realms at its own start). + Container owner = combined.getContainer(); + if (owner != null) { + realm.setContainer(owner); + if (realm instanceof RealmBase realmBase) { + realmBase + .setRealmPath(combined.getRealmPath() + "/realm" + (combined.getNestedRealms().length - 1)); + } + } + if (running && realm instanceof Lifecycle lifecycle) { + lifecycle.start(); + } + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + combined.removeRealm(realm); + if (realm instanceof Lifecycle lifecycle && lifecycle.getState().isAvailable()) { + try { + lifecycle.stop(); + } catch (Exception ignored) { + // Best effort; the removal failure is already logged. + } + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(sm.getString("manager2.configAuditAdd", "realm", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + /** + * The number of realm levels in the chain starting at the given realm (the realm itself counts as one), following + * the first sub realm of each combined realm. + */ + private static int nestedRealmDepth(Realm realm) { + int depth = 0; + for (Realm current = realm; current != null; current = firstSubRealm(current)) { + depth++; + } + return depth; + } + + + private static Realm firstSubRealm(Realm realm) { + if (!(realm instanceof CombinedRealm combined)) { + return null; + } + Realm[] nested = combined.getNestedRealms(); + return nested.length > 0 ? nested[0] : null; + } + + + /** + * Add (replace) one of the sub components that a context holds exactly one of: the manager, the resources, the + * loader or the cookie processor. A context always has one of each (the defaults are created at context start), so + * the current instance is replaced by a new instance of the given class. The component class is a server level + * class and is identified by its class name. + */ + private void addContextComponent(HttpServletResponse response, NodeRef parent, Map body, + String type) throws Exception { + + if (!(parent.component instanceof Context context)) { + throw badParent(type); + } + // Replacing the session manager (or the class loader) of this + // web application's own context would destroy the admin session + // (or the classes of the running application) mid-request. + if (context == selfContext && ("manager".equals(type) || "loader".equals(type))) { + throw self(); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + Object component; + try { + // These are server level classes: never use the webapp class + // loader. + Object instance = Class.forName(className, true, server.getClass().getClassLoader()).getConstructor() + .newInstance(); + switch (type) { + case "manager" -> component = (Manager) instance; + case "resources" -> component = (WebResourceRoot) instance; + case "loader" -> component = (Loader) instance; + default -> component = (CookieProcessor) instance; + } + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + + boolean running = context.getState().isAvailable(); + switch (type) { + case "manager" -> { + Manager oldManager = context.getManager(); + try { + // setManager wires the manager to the context and, on + // a running context, stops the old manager and starts + // the new one. + context.setManager((Manager) component); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + // A manager that does not start on a running context is + // not useful; roll the change back. On a stopped context + // the manager is started when the context starts. + if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { + context.setManager(oldManager); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", className, "the component did not start")); + } + } + case "loader" -> { + Loader oldLoader = context.getLoader(); + try { + // setLoader wires the loader to the context and, on a + // running context, stops the old loader and starts + // the new one. + context.setLoader((Loader) component); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { + context.setLoader(oldLoader); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", className, "the component did not start")); + } + } + case "resources" -> { + // The context refuses to change its resources while it + // is running: the resource tree of a live web + // application cannot be swapped out from under it. + if (running) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONTEXT_RUNNING", + sm.getString("manager2.configContextMustBeStopped", displayName(context, "context"))); + } + try { + // setResources wires the resources to the context. + // Their lifecycle is driven by the context (start). + context.setResources((WebResourceRoot) component); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + } + default -> { + // The cookie processor has no lifecycle; the context + // uses it from the moment it is set. + try { + context.setCookieProcessor((CookieProcessor) component); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + } + } + log(sm.getString("manager2.configAuditAdd", type, className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + /** + * Add (replace) the session id generator of a manager. The manager uses it from the moment it is set; on a running + * manager the old generator is stopped and the new one started. + */ + private void addSessionIdGenerator(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + + if (!(parent.component instanceof Manager manager)) { + throw badParent("sessionIdGenerator"); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", "className")); + } + SessionIdGenerator generator; + try { + // Session id generators are server level classes: never use + // the webapp class loader. + generator = (SessionIdGenerator) Class.forName(className, true, server.getClass().getClassLoader()) + .getConstructor().newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + + // The Manager interface does not extend Lifecycle; all standard + // managers do (ManagerBase), so the state is read conditionally. + boolean running = manager instanceof Lifecycle && ((Lifecycle) manager).getState().isAvailable(); + SessionIdGenerator old = manager.getSessionIdGenerator(); + boolean oldRunning = running && old instanceof Lifecycle && ((Lifecycle) old).getState().isAvailable(); + try { + if (oldRunning) { + ((Lifecycle) old).stop(); + } + manager.setSessionIdGenerator(generator); + // The manager stamps its jvm route onto the generator at + // start; mirror that for a live replacement. + if (manager instanceof ManagerBase managerBase) { + generator.setJvmRoute(managerBase.getJvmRoute()); + } + if (running && generator instanceof Lifecycle lifecycle) { + lifecycle.start(); + } + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + // Best effort rollback: stop the new generator (if it + // started) and restore the previous one (the setter rejects + // null, so "none" cannot be restored). + try { + if (generator instanceof Lifecycle lifecycle && lifecycle.getState().isAvailable()) { + lifecycle.stop(); + } + if (manager.getSessionIdGenerator() == generator && old != null) { + manager.setSessionIdGenerator(old); + } + if (oldRunning && old instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { + lifecycle.start(); + } + } catch (Exception ignored) { + // Best effort; the failure is already logged. + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(sm.getString("manager2.configAuditAdd", "sessionIdGenerator", className)); + Api.ok(response, sm.getString("manager2.configAdded", className)); + } + + + /** + * Add one JNDI entry to a {@code NamingResourcesImpl}. The entry takes effect in the live JNDI environment of the + * server (the global context) or of the context ({@code java:comp/env}) immediately: the add fires the property + * change event the {@code NamingContextListener} reacts to. + *

+ * The request body carries the entry fields: {@code name} (the JNDI name), {@code jndiType} (the type of the + * object) and the type specific fields ({@code auth}, {@code factory}, ...). A nested {@code params} object is + * applied as the free form string parameters of the entry; for the first party JNDI factories with a closed set of + * parameters the values are validated against the type the factory parses them as. + */ + private void addNamingEntry(HttpServletResponse response, NodeRef parent, Map body, String type) + throws Exception { + + if (!(parent.component instanceof NamingResourcesImpl namingResources)) { + throw badParent(type); + } + // Resource links are only part of a context JNDI environment; + // they are not parsed from . + if ("resourceLink".equals(type) && namingResources.getContainer() instanceof Server) { + throw badParent(type); + } + String name = string(body.get("name")); + if (name == null || name.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + if (findNamingEntry(namingResources, type, name) != null) { + throw duplicate(name); + } + String jndiType = string(body.get("jndiType")); + if (jndiType == null || jndiType.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", + sm.getString("manager2.configJndiTypeRequired", type)); + } + + ResourceBase entry; + switch (type) { + case "resource" -> { + ContextResource resource = new ContextResource(); + resource.setName(name); + resource.setType(jndiType); + String auth = string(body.get("auth")); + if (auth != null) { + resource.setAuth(auth); + } + resource.setSingleton(bool(body.get("singleton"), true)); + String closeMethod = string(body.get("closeMethod")); + if (closeMethod != null) { + resource.setCloseMethod(closeMethod); + } + String lookupName = string(body.get("lookupName")); + if (lookupName != null) { + resource.setLookupName(lookupName); + } + String description = string(body.get("description")); + if (description != null) { + resource.setDescription(description); + } + entry = resource; + } + case "resourceLink" -> { + ContextResourceLink link = new ContextResourceLink(); + link.setName(name); + link.setType(jndiType); + String global = string(body.get("global")); + if (global == null || global.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", + sm.getString("manager2.configJndiGlobalRequired")); + } + link.setGlobal(global); + String description = string(body.get("description")); + if (description != null) { + link.setDescription(description); + } + entry = link; + } + case "resourceEnvRef" -> { + ContextResourceEnvRef resourceEnvRef = new ContextResourceEnvRef(); + resourceEnvRef.setName(name); + resourceEnvRef.setType(jndiType); + resourceEnvRef.setOverride(bool(body.get("override"), true)); + String description = string(body.get("description")); + if (description != null) { + resourceEnvRef.setDescription(description); + } + entry = resourceEnvRef; + } + case "environment" -> { + ContextEnvironment environment = new ContextEnvironment(); + environment.setName(name); + environment.setType(jndiType); + String value = string(body.get("value")); + if (value != null) { + environment.setValue(value); + } + environment.setOverride(bool(body.get("override"), true)); + String description = string(body.get("description")); + if (description != null) { + environment.setDescription(description); + } + entry = environment; + } + case "ejb" -> { + ContextEjb ejb = new ContextEjb(); + ejb.setName(name); + ejb.setType(jndiType); + String home = string(body.get("home")); + if (home != null) { + ejb.setHome(home); + } + String link = string(body.get("link")); + if (link != null) { + ejb.setLink(link); + } + String remote = string(body.get("remote")); + if (remote != null) { + ejb.setRemote(remote); + } + String description = string(body.get("description")); + if (description != null) { + ejb.setDescription(description); + } + entry = ejb; + } + case "localEjb" -> { + ContextLocalEjb localEjb = new ContextLocalEjb(); + localEjb.setName(name); + localEjb.setType(jndiType); + String local = string(body.get("local")); + if (local != null) { + localEjb.setLocal(local); + } + String home = string(body.get("home")); + if (home != null) { + localEjb.setHome(home); + } + String link = string(body.get("link")); + if (link != null) { + localEjb.setLink(link); + } + String description = string(body.get("description")); + if (description != null) { + localEjb.setDescription(description); + } + entry = localEjb; + } + default -> { + ContextService service = new ContextService(); + service.setName(name); + service.setType(jndiType); + String serviceInterface = string(body.get("interface")); + if (serviceInterface != null) { + service.setInterface(serviceInterface); + } + String displayname = string(body.get("displayname")); + if (displayname != null) { + service.setDisplayname(displayname); + } + String wsdlfile = string(body.get("wsdlfile")); + if (wsdlfile != null) { + service.setWsdlfile(wsdlfile); + } + String description = string(body.get("description")); + if (description != null) { + service.setDescription(description); + } + entry = service; + } + } + + // The factory: a string parameter of a resource, a first class + // attribute of a resource link. + String factory = string(body.get("factory")); + if (factory != null && !factory.isEmpty()) { + checkFactoryLoadable(factory); + if (entry instanceof ContextResourceLink link) { + link.setFactory(factory); + } else if (entry instanceof ContextResource) { + entry.setProperty("factory", factory); + } + } + // The generic string parameters of the entry (the ResourceBase + // property map). For the types whose factory is one of the first + // party factories with a closed set of options the values are + // validated against that set; for the others (and for open set + // factories) they are stored as-is. + applyNamingParams(entry, factory, body.get("params")); + + try { + addNamingEntryTo(namingResources, type, entry); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", name, rootMessage(e))); + } + log(sm.getString("manager2.configAuditAdd", type, name)); + Api.ok(response, sm.getString("manager2.configAdded", name)); + } + + + /** + * Apply the free form string parameters of a new JNDI entry, validating the values against the closed parameter set + * of the first party factory (the factories parse them at JNDI lookup time, which is too late for a useful error + * message). + */ + private void applyNamingParams(ResourceBase entry, String factory, Object params) throws ConfigException { + if (!(params instanceof Map map) || map.isEmpty()) { + return; + } + List options = factoryOptions(factory); + for (Map.Entry e : map.entrySet()) { + String key = String.valueOf(e.getKey()); + String value = string(e.getValue()); + if (value == null) { + continue; + } + if (options != null) { + for (ExplicitAttribute option : options) { + if (option.getName().equals(key)) { + validateParamValue(key, value, option.getType()); + break; + } + } + } + entry.setProperty(key, value); + } + } + + + private static void validateParamValue(String name, String value, String type) throws ConfigException { + try { + String trimmed = value.trim(); + switch (type) { + case "int" -> Integer.parseInt(trimmed); + case "long" -> Long.parseLong(trimmed); + case "boolean" -> { + String v = trimmed.toLowerCase(Locale.ROOT); + if (!"true".equals(v) && !"false".equals(v)) { + throw new NumberFormatException(trimmed); + } + } + default -> { + // A string. + } + } + } catch (NumberFormatException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", + sm.getString("manager2.configSetFailed", name, sm.getString("manager2.configInvalidType", type))); + } + } + + + private void checkFactoryLoadable(String factory) throws ConfigException { + try { + // The factory is instantiated with the container class + // loader at JNDI lookup time. + Class.forName(factory, false, server.getClass().getClassLoader()); + } catch (ClassNotFoundException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configFactoryInvalid", factory)); + } + } + + + private void addConnector(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof StandardService service)) { + throw badParent("connector"); + } + String protocol = string(body.get("protocol")); + if (protocol == null || protocol.isEmpty()) { + protocol = "HTTP/1.1"; + } + Integer port = intValue(body.get("port"), -1); + if (port < 1 || port > 65535) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configInvalidValue", "port")); + } + Connector connector = new Connector(protocol); + connector.setPort(port); + addChecked(connectorLabel(connector), connector, () -> service.addConnector(connector), + () -> service.removeConnector(connector)); + log(sm.getString("manager2.configAuditAdd", "connector", protocol + " (port " + port + ")")); + Api.ok(response, sm.getString("manager2.configAdded", protocol + " (port " + port + ")")); + } + + + private void addExecutor(HttpServletResponse response, NodeRef parent, Map body) throws Exception { + + if (!(parent.component instanceof StandardService service)) { + throw badParent("executor"); + } + String name = requiredName(body.get("name")); + for (Executor executor : service.findExecutors()) { + if (executor.getName().equals(name)) { + throw duplicate(name); + } + } + StandardThreadExecutor executor = new StandardThreadExecutor(); + executor.setName(name); + Integer maxThreads = intValue(body.get("maxThreads"), -1); + if (maxThreads > 0) { + executor.setMaxThreads(maxThreads); + } + Integer minSpare = intValue(body.get("minSpareThreads"), -1); + if (minSpare >= 0) { + executor.setMinSpareThreads(minSpare); + } + // The thread pool refuses to start when the minimum number of + // threads exceeds the maximum (the defaults are 25 and 200, so an + // explicit maxThreads below the default minimum must be rejected). + if (executor.getMinSpareThreads() > executor.getMaxThreads()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configExecutorInvalid")); + } + addChecked(name, executor, () -> service.addExecutor(executor), () -> service.removeExecutor(executor)); + log(sm.getString("manager2.configAuditAdd", "executor", name)); + Api.ok(response, sm.getString("manager2.configAdded", name)); + } + + + /** + * Add an SSL host configuration (one TLS virtual host) to a connector. Adding the first SSL host configuration + * enables TLS on the connector. A running connector is restarted so that the TLS configuration takes effect; when + * the restart fails (typically because no valid certificate is configured) the change is rolled back and the + * connector is left running as it was. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + + if (!(parent.component instanceof Connector connector)) { + throw badParent("sslHostConfig"); + } + AbstractHttp11Protocol http11 = sslProtocolHandler(connector); + if (http11 == null) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", + sm.getString("manager2.configSslUnsupported", connector.getProtocolHandlerClassName())); + } + if (isSelfConnector(connector)) { + throw self(); + } + String hostName = string(body.get("hostName")); + if (hostName == null || hostName.isEmpty()) { + // The default host configuration name ("_default_") is not + // exposed by a public constant. + hostName = "_default_"; + } + if (!SAFE_NAME.matcher(hostName).matches()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", hostName)); + } + String hostNameLower = hostName.toLowerCase(Locale.ENGLISH); + SSLHostConfig[] existing = connector.findSslHostConfigs(); + if (existing != null) { + for (SSLHostConfig hostConfig : existing) { + if (hostConfig.getHostName().equals(hostNameLower)) { + throw duplicate(hostNameLower); + } + } + } + + boolean wasSslEnabled = http11.isSSLEnabled(); + boolean wasRunning = connector.getState().isAvailable(); + + SSLHostConfig sslHostConfig = new SSLHostConfig(); + sslHostConfig.setHostName(hostNameLower); + // An optional initial certificate. On a running connector that + // is not TLS enabled yet it is required: a connector without + // any certificate cannot complete a single TLS handshake. + boolean certificateProvided = body.get("certificate") instanceof Map; + if (wasRunning && !wasSslEnabled && !certificateProvided) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configSslCertificateRequired")); + } + if (certificateProvided) { + SSLHostConfigCertificate certificate = buildCertificate(sslHostConfig, + (Map) body.get("certificate")); + try { + sslHostConfig.addCertificate(certificate); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configInvalidValue", "certificate")); + } + } + + try { + connector.addSslHostConfig(sslHostConfig); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); + } + + if (wasRunning && !wasSslEnabled) { + // The endpoint is already bound, so its TLS configuration + // (SSL implementation and the SSL contexts, which is where + // the keystores are loaded) is initialized here instead of + // at bind time. This validates the new TLS configuration + // without interrupting the connector: from this point on, + // new connections are served over TLS. + try { + endpointOf(connector).initialiseSsl(); + } catch (Exception e) { + // TLS is switched off first: the endpoint refuses to + // remove its default host configuration while it is + // still serving TLS. + http11.setSSLEnabled(false); + try { + endpointOf(connector).removeSslHostConfig(hostNameLower); + } catch (Exception e2) { + log(sm.getString("manager2.configRollbackFailed", hostNameLower, rootMessage(e2)), e2); + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); + } + } + + log(sm.getString("manager2.configAuditAdd", "sslHostConfig", hostNameLower)); + Api.ok(response, sm.getString("manager2.configAdded", hostNameLower)); + } + + + /** + * Add a certificate configuration to an SSL host configuration. On a running, TLS enabled connector the new + * certificate is applied at once (the SSL context of the virtual host is re-created, which also validates the + * keystore); when that fails the certificate is rolled back. + */ + @SuppressWarnings("rawtypes") + private void addCertificate(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + + if (!(parent.component instanceof SSLHostConfig sslHostConfig)) { + throw badParent("certificate"); + } + SSLHostConfigCertificate certificate = buildCertificate(sslHostConfig, body); + try { + sslHostConfig.addCertificate(certificate); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", certificateLabel(certificate), rootMessage(e))); + } + + Connector connector = connectorOfSsl(sslHostConfig); + AbstractHttp11Protocol http11 = (connector != null) ? sslProtocolHandler(connector) : null; + if (connector != null && connector.getState().isAvailable() && http11 != null && http11.isSSLEnabled()) { + try { + endpointOf(connector).reloadSslHostConfig(sslHostConfig.getHostName()); + } catch (Exception e) { + sslHostConfig.getCertificates().remove(certificate); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + sm.getString("manager2.configAddFailed", certificateLabel(certificate), rootMessage(e))); + } + } + + log(sm.getString("manager2.configAuditAdd", "certificate", certificateLabel(certificate))); + Api.ok(response, sm.getString("manager2.configAdded", certificateLabel(certificate))); + } + + + /** + * Build a (not yet registered) certificate configuration from a JSON object. The same builder is used for the + * certificate of a new SSL host configuration and for standalone certificates. + */ + private static SSLHostConfigCertificate buildCertificate(SSLHostConfig sslHostConfig, Map body) + throws ConfigException { + String typeName = string(body.get("type")); + SSLHostConfigCertificate.Type type; + if (typeName == null || typeName.isEmpty()) { + type = SSLHostConfigCertificate.Type.UNDEFINED; + } else { + try { + type = SSLHostConfigCertificate.Type.valueOf(typeName.trim().toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", typeName)); + } + } + SSLHostConfigCertificate certificate = new SSLHostConfigCertificate(sslHostConfig, type); + setIfPresent(certificate, "certificateKeystoreFile", body); + setIfPresent(certificate, "certificateKeystorePassword", body); + setIfPresent(certificate, "certificateKeystorePasswordFile", body); + setIfPresent(certificate, "certificateKeystoreType", body); + setIfPresent(certificate, "certificateKeystoreProvider", body); + setIfPresent(certificate, "certificateKeyAlias", body); + setIfPresent(certificate, "certificateKeyPassword", body); + setIfPresent(certificate, "certificateKeyPasswordFile", body); + setIfPresent(certificate, "certificateFile", body); + setIfPresent(certificate, "certificateChainFile", body); + setIfPresent(certificate, "certificateKeyFile", body); + return certificate; + } + + + /** + * Call one string setter on the target when the body carries a non-empty value for the property. + */ + private static void setIfPresent(Object target, String name, Map body) throws ConfigException { + Object value = body.get(name); + if (value == null) { + return; + } + String s = String.valueOf(value); + if (s.isEmpty()) { + return; + } + try { + target.getClass().getMethod("set" + capitalize(name), String.class).invoke(target, s); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", + sm.getString("manager2.configInvalidValue", name)); + } + } + + + /** + * The protocol handler of the connector when it is the HTTP/1.1 variant that supports TLS, otherwise {@code null} + * (for example an AJP connector). + */ + @SuppressWarnings("rawtypes") + private static AbstractHttp11Protocol sslProtocolHandler(Connector connector) { + if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol http11) { + return http11; + } + return null; + } + + + /** + * Whether the connector's protocol handler has TLS enabled. + */ + @SuppressWarnings("rawtypes") + private static boolean isSslEnabled(Connector connector) { + AbstractHttp11Protocol http11 = sslProtocolHandler(connector); + return http11 != null && http11.isSSLEnabled(); + } + + + /** + * The connector that owns the given SSL host configuration, or {@code null} when it is not attached to any + * connector. + */ + private Connector connectorOfSsl(SSLHostConfig sslHostConfig) { + for (Service service : server.findServices()) { + for (Connector connector : service.findConnectors()) { + SSLHostConfig[] hostConfigs = connector.findSslHostConfigs(); + if (hostConfigs == null) { + continue; + } + for (SSLHostConfig candidate : hostConfigs) { + if (candidate == sslHostConfig) { + return connector; + } + } + } + } + return null; + } + + + /** + * The endpoint of the connector's protocol handler. The endpoint (and with it the SSL host configuration operations + * that are not on the {@code ProtocolHandler} interface, such as {@code removeSslHostConfig} and + * {@code reloadSslHostConfig}) is only reachable through the protected accessor of {@code AbstractProtocol}, so it + * is invoked reflectively. + */ + private AbstractEndpoint endpointOf(Connector connector) throws ConfigException { + try { + Method m = AbstractProtocol.class.getDeclaredMethod("getEndpoint"); + m.setAccessible(true); + return (AbstractEndpoint) m.invoke(connector.getProtocolHandler()); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SERVER_UNAVAILABLE", + sm.getString("manager2.configServerUnavailable")); + } + } + + + /** + * Whether the connector routes requests for the host that deploys this web application (the connector must never be + * stopped or restarted, as that would destroy the admin session mid-request). + */ + private boolean isSelfConnector(Connector connector) { + Service service = connector.getService(); + return service != null && service.getContainer() != null && selfHost.getParent() == service.getContainer(); + } + + + private void removeChild(HttpServletResponse response, Map body) throws Exception { + + String id = string(body.get("id")); + if (id == null) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + NodeRef ref = resolve(id); + + // Guards against removing the components that host this web + // application (the session would be destroyed mid-request). An + // alias is excluded: it is not a container, so removing one cannot + // change where this web application is deployed, even on the + // manager's own host. + if (!"alias".equals(ref.type) && ref.component == selfContext) { + throw self(); + } + if (!"alias".equals(ref.type) && ref.component == selfHost) { + throw self(); + } + // The last service cannot be removed. Checked before the service + // self-host check: with a single service both apply and the + // structural invariant is the more informative answer. + if (ref.type.equals("service") && server.findServices().length <= 1) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "LAST_SERVICE", + sm.getString("manager2.configLastService")); + } + if (ref.type.equals("service") && containsSelf((StandardService) ref.component)) { + throw self(); + } + if (ref.type.equals("engine") && ref.component instanceof Engine engine && + engine.findChild(selfHost.getName()) instanceof Host) { + throw self(); + } + + if (ref.type.equals("valve") && isBasicValve(ref)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BASIC_COMPONENT", + sm.getString("manager2.configBasicValve")); + } + // The sub components a context holds exactly one of (and the + // manager's session id generator) are required: they cannot be + // removed, only replaced. + if (Set.of("manager", "resources", "loader", "cookieProcessor", "sessionIdGenerator").contains(ref.type)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REQUIRED_COMPONENT", + sm.getString("manager2.configRequiredComponent", ref.type)); + } + // The JNDI naming resources of the server and of a context are + // always present and required: they cannot be removed. + if (ref.type.equals("namingResources")) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REQUIRED_COMPONENT", + sm.getString("manager2.configRequiredNamingResources")); + } + // Cluster valves and channel interceptors are repeatable sub + // components that have no removal API on a running cluster; only + // the cluster itself and its single-valued sub components can be + // detached. + if (ref.type.equals("clusterValve") || ref.type.equals("interceptor")) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_NOT_SUPPORTED", + sm.getString("manager2.configRemoveNotSupported", ref.type)); + } + // A service holds exactly one engine (its container); it cannot be + // removed while it still contains hosts. + if (ref.type.equals("engine") && ref.component instanceof Engine engine && countHosts(engine) > 0) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "NOT_EMPTY", + sm.getString("manager2.configNotEmpty", "engine")); + } + // A directly attached realm can only be removed when the container + // falls back to a parent realm afterwards; otherwise the container + // (and everything below it) would have no realm at all. + if (ref.type.equals("realm") && ref.parent instanceof Container container && fallbackRealm(container) == null) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "LAST_REALM", + sm.getString("manager2.configLastRealm")); + } + + String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); + if (Set.of("host", "context", "service", "engine", "connector", "executor", "wrapper", "valve", "sslHostConfig", + "realm", "cluster", "resource", "resourceLink", "resourceEnvRef", "environment", "ejb", "localEjb", + "serviceRef").contains(ref.type)) { + String confirm = string(body.get("confirm")); + if (!label.equals(confirm)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", + sm.getString("manager2.configConfirmRequired", label)); + } + } + + if ("sslHostConfig".equals(ref.type)) { + removeSslHostConfig(response, ref, label); + return; + } + if ("certificate".equals(ref.type)) { + removeCertificate(response, ref); + return; + } + + try { + switch (ref.type) { + case "host" -> ((Container) ref.parent).removeChild((Host) ref.component); + case "context" -> ((Container) ref.parent).removeChild((Context) ref.component); + case "wrapper" -> ((Container) ref.parent).removeChild((Wrapper) ref.component); + case "valve" -> ((Container) ref.parent).getPipeline().removeValve((Valve) ref.component); + case "connector" -> ((StandardService) ref.parent).removeConnector((Connector) ref.component); + case "executor" -> ((StandardService) ref.parent).removeExecutor((Executor) ref.component); + case "alias" -> ((Host) ref.component).removeAlias(ref.aliasValue); + case "listener" -> + ((LifecycleBase) ref.parent).removeLifecycleListener((LifecycleListener) ref.component); + case "realm" -> removeRealm((Realm) ref.component, ref.parent); + case "engine" -> ((StandardService) ref.parent).setContainer(null); + case "service" -> server.removeService((Service) ref.component); + case "resource" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "resource", + ((ResourceBase) ref.component).getName()); + case "resourceLink" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "resourceLink", + ((ResourceBase) ref.component).getName()); + case "resourceEnvRef" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "resourceEnvRef", + ((ResourceBase) ref.component).getName()); + case "environment" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "environment", + ((ResourceBase) ref.component).getName()); + case "ejb" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "ejb", + ((ResourceBase) ref.component).getName()); + case "localEjb" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "localEjb", + ((ResourceBase) ref.component).getName()); + case "serviceRef" -> removeNamingEntry((NamingResourcesImpl) ref.parent, "serviceRef", + ((ResourceBase) ref.component).getName()); + case "cluster" -> ((Container) ref.parent).setCluster(null); + case "channel" -> ((CatalinaCluster) ref.parent).setChannel(null); + case "membership" -> ((ManagedChannel) ref.parent).setMembershipService(null); + case "sender" -> ((ManagedChannel) ref.parent).setChannelSender(null); + case "receiver" -> ((ManagedChannel) ref.parent).setChannelReceiver(null); + case "deployer" -> ((CatalinaCluster) ref.parent).setClusterDeployer(null); + case "clusterManager" -> ((SimpleTcpCluster) ref.parent).setManagerTemplate(null); + case "transport" -> ((ReplicationTransmitter) ref.parent).setTransport(null); + case "clusterListener" -> + ((CatalinaCluster) ref.parent).removeClusterListener((ClusterListener) ref.component); + case "member" -> removeClusterMember(ref); + default -> throw notFound(); + } + } catch (ConfigException e) { + throw e; + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", + sm.getString("manager2.configRemoveFailed", + e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + + log(sm.getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, sm.getString("manager2.configRemoved", label)); + } + + + /** + * Remove a static member (the local member or a static member) from a static membership service or interceptor. + */ + private void removeClusterMember(NodeRef ref) { + Member member = (Member) ref.component; + if (ref.parent instanceof StaticMembershipService sms) { + if (sms.getLocalMember(false) == member) { + sms.setLocalMember(null); + } else { + sms.removeStaticMember((StaticMember) member); + } + } else if (ref.parent instanceof StaticMembershipInterceptor smi) { + if (smi.getLocalMember(false) == member) { + smi.setLocalMember(null); + } else { + smi.removeStaticMember(member); + } + } + } + + + /** + * Remove a realm. A directly attached realm is detached from its container (the container then falls back to the + * parent realm) and a sub realm is removed from its combined realm and stopped. + */ + private static void removeRealm(Realm realm, Object parent) throws Exception { + if (parent instanceof CombinedRealm combined) { + if (!combined.removeRealm(realm)) { + throw notFound(); + } + if (realm instanceof Lifecycle lifecycle && lifecycle.getState().isAvailable()) { + lifecycle.stop(); + } + } else if (parent instanceof Container container) { + // Detach: the container falls back to the parent realm. + container.setRealm(null); + } else { + throw notFound(); + } + } + + + /** + * Remove an SSL host configuration from its connector. When this disables TLS on the connector (no configurations + * left) and the connector is running, it is restarted so that it serves plain HTTP again, with a rollback that + * restores the configuration. + */ + @SuppressWarnings("rawtypes") + private void removeSslHostConfig(HttpServletResponse response, NodeRef ref, String label) throws Exception { + + if (!(ref.component instanceof SSLHostConfig sslHostConfig) || !(ref.parent instanceof Connector connector)) { + throw notFound(); + } + if (isSelfConnector(connector)) { + throw self(); + } + AbstractHttp11Protocol http11 = sslProtocolHandler(connector); + boolean sslEnabled = isSslEnabled(connector); + boolean running = connector.getState().isAvailable(); + int remaining = (connector.findSslHostConfigs() == null) ? 0 : connector.findSslHostConfigs().length - 1; + if (sslEnabled && running && remaining > 0 && + sslHostConfig.getHostName().equalsIgnoreCase(endpointOf(connector).getDefaultSSLHostConfigName())) { + // The default host configuration is the fallback for + // handshakes without a matching SNI name; it cannot be + // removed from a running, TLS enabled connector while other + // configurations remain. + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SSL_DEFAULT", + sm.getString("manager2.configSslDefault")); + } + + boolean disablesSsl = sslEnabled && remaining == 0; + if (disablesSsl) { + // TLS is disabled for this connector from now on. The flag + // is switched off before the removal because the endpoint + // refuses to remove its default host configuration while it + // is still serving TLS. + http11.setSSLEnabled(false); + } + try { + endpointOf(connector).removeSslHostConfig(sslHostConfig.getHostName()); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", + sm.getString("manager2.configRemoveFailed", rootMessage(e))); + } + // When the removal disabled TLS no further action is needed: the + // endpoint decides per accepted connection whether it is TLS, so + // new connections are served over plain HTTP from this point on + // (in-flight TLS connections complete normally). + + log(sm.getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, sm.getString("manager2.configRemoved", label)); + } + + + /** + * Remove a certificate configuration from its SSL host configuration. On a running, TLS enabled connector the + * change is applied at once (the SSL context of the virtual host is re-created) and rolled back when the remaining + * configuration does not validate. The last certificate of such a connector cannot be removed: the connector would + * no longer be able to start. + */ + private void removeCertificate(HttpServletResponse response, NodeRef ref) throws Exception { + + if (!(ref.component instanceof SSLHostConfigCertificate certificate) || + !(ref.parent instanceof SSLHostConfig sslHostConfig)) { + throw notFound(); + } + Connector connector = connectorOfSsl(sslHostConfig); + boolean live = connector != null && connector.getState().isAvailable() && isSslEnabled(connector); + if (live && sslHostConfig.getCertificates().size() <= 1) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SSL_LAST_CERTIFICATE", + sm.getString("manager2.configSslLastCertificate", connectorLabel(connector))); + } + + Set certificates = sslHostConfig.getCertificates(); + certificates.remove(certificate); + if (live) { + try { + endpointOf(connector).reloadSslHostConfig(sslHostConfig.getHostName()); + } catch (Exception e) { + certificates.add(certificate); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", + sm.getString("manager2.configRemoveFailed", rootMessage(e))); + } + } + + String label = displayName(certificate, "certificate"); + log(sm.getString("manager2.configAuditRemove", "certificate", label)); + Api.ok(response, sm.getString("manager2.configRemoved", label)); + } + + + private boolean containsSelf(StandardService service) { + Engine engine = service.getContainer(); + return engine != null && engine.findChild(selfHost.getName()) instanceof Host; + } + + + private static int countHosts(Engine engine) { + int count = 0; + for (Container child : engine.findChildren()) { + if (child instanceof Host) { + count++; + } + } + return count; + } + + + private boolean isBasicValve(NodeRef ref) { + // The first valve of a pipeline is the basic valve and cannot be + // removed. + if (ref.parent instanceof Container container) { + Valve[] valves = container.getPipeline().getValves(); + return valves.length > 0 && valves[0] == ref.component; + } + return false; + } + + + private static ConfigException self() { + return new ConfigException(HttpServletResponse.SC_FORBIDDEN, "SELF_COMPONENT", + sm.getString("manager2.configSelfComponent")); + } + + + private static ConfigException duplicate(String name) { + return new ConfigException(HttpServletResponse.SC_CONFLICT, "DUPLICATE", + sm.getString("manager2.configDuplicate", name)); + } + + + private static ConfigException badParent(String type) { + return new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", + sm.getString("manager2.configParentInvalid", type)); + } + + + // ------------------------------------------------------------- Store + + + /** + * Build a ready to use {@link StoreConfig} that writes the live state of this server. The storeconfig MBean is not + * required (and is not registered unless the {@code StoreConfigLifecycleListener} is configured in server.xml), so + * the instance is created on demand from the default classpath registry. + */ + private StoreConfig buildStoreConfig() throws Exception { + StoreLoader loader = new StoreLoader(); + loader.load(null); + StoreConfig storeConfig = new StoreConfig(); + storeConfig.setRegistry(loader.getRegistry()); + storeConfig.setServer(server); + return storeConfig; + } + + + private void store(HttpServletResponse response) throws Exception { + + File confDir = new File(storeBase(), "conf"); + Set before = serverXmlBackups(confDir); + + StoreConfig storeConfig = buildStoreConfig(); + // Same sequence as StoreConfig.store(Server), but keeping each + // context in its current storage location (see + // storeServerPreservingContexts). + StoreFileMover mover = new StoreFileMover(storeBase(), storeConfig.getServerFilename(), + storeConfig.getRegistry().getEncoding()); + try (PrintWriter writer = mover.getWriter()) { + storeServerPreservingContexts(storeConfig, writer); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "STORE_FAILED", + sm.getString("manager2.configStoreFailed")); + } + mover.move(); + + Set after = serverXmlBackups(confDir); + after.removeAll(before); + String backup = after.isEmpty() ? null : after.iterator().next(); + + log(sm.getString("manager2.configAuditStore", backup)); + Map payload = new LinkedHashMap<>(); + payload.put("ok", Boolean.TRUE); + payload.put("message", sm.getString("manager2.configStored", "conf/server.xml", backup == null ? "-" : backup)); + payload.put("file", "conf/server.xml"); + payload.put("backup", backup); + Api.json(response, payload); + } + + + /** + * The Catalina base directory that the store writes to. Normally the base of the running server. The + * {@code manager2.store.base} system property overrides it (used by the integration tests to redirect the write to + * a throw-away directory). + */ + private static String storeBase() { + String base = System.getProperty("manager2.store.base"); + return (base != null && !base.isEmpty()) ? base : Bootstrap.getCatalinaBase(); + } + + + /** + * The names of the storeconfig backup files ({@code server.xml.*}) in the conf directory. + */ + private static Set serverXmlBackups(File confDir) { + Set result = new HashSet<>(); + File[] files = confDir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile() && file.getName().startsWith("server.xml.")) { + result.add(file.getName()); + } + } + } + return result; + } + + + private void preview(HttpServletResponse response) throws Exception { + StoreConfig storeConfig = buildStoreConfig(); + StringWriter writer = new StringWriter(); + CapturingContextSF capturing = previewServerPreservingContexts(storeConfig, writer); + Map payload = new LinkedHashMap<>(); + payload.put("xml", writer.toString()); + payload.put("files", new ArrayList(capturing.getCaptured().keySet())); + // The save restarts the manager (and resets the admin session) only + // when the file of the context this manager runs in is rewritten. + payload.put("restartsManager", selfContext != null && selfContext.getConfigFile() != null); + Api.json(response, payload); + } + + + /** + * Store the live server state to the given writer, keeping every context in its current storage location, as + * regular StoreConfig does. The external context files are written to disk. + */ + private void storeServerPreservingContexts(StoreConfig storeConfig, PrintWriter writer) throws Exception { + storeServer(storeConfig, writer, null); + } + + + /** + * Read-only variant of {@link #storeServerPreservingContexts} used by the store preview: the external context files + * are captured in memory (see {@link CapturingContextSF}) instead of being written to disk, so the preview never + * modifies the configuration on disk. + */ + private CapturingContextSF previewServerPreservingContexts(StoreConfig storeConfig, StringWriter writer) + throws Exception { + CapturingContextSF capturing = new CapturingContextSF(); + storeServer(storeConfig, new PrintWriter(writer), capturing); + return capturing; + } + + + /** + * Store the live server state to the given writer, keeping each context in the location it currently lives in, as + * regular StoreConfig does: a context backed by its own configuration file is written back to that file, a context + * defined inline stays inline in {@code server.xml}. + *

+ * The flags are set so that external contexts are written to their file ({@code storeSeparate=true}, + * {@code externalAllowed=true}) while inline contexts are not turned into new external files + * ({@code externalOnly=false}). The previous flag values (and the store factory, when {@code capturing} is used) + * are restored afterwards. When {@code capturing} is non-null (the read-only preview), the external files are + * captured in memory by that factory instead of being written. + */ + private void storeServer(StoreConfig storeConfig, PrintWriter writer, CapturingContextSF capturing) + throws Exception { + + StoreDescription desc = storeConfig.getRegistry().findDescription(StandardContext.class); + boolean oldSeparate = desc.isStoreSeparate(); + boolean oldAllowed = desc.isExternalAllowed(); + boolean oldOnly = desc.isExternalOnly(); + IStoreFactory oldFactory = desc.getStoreFactory(); + try { + desc.setStoreSeparate(true); + desc.setExternalAllowed(true); + desc.setExternalOnly(false); + if (capturing != null) { + capturing.setRegistry(storeConfig.getRegistry()); + if (oldFactory != null) { + capturing.setStoreAppender(oldFactory.getStoreAppender()); + } + desc.setStoreFactory(capturing); + } + storeConfig.store(writer, -2, server); + } finally { + desc.setStoreSeparate(oldSeparate); + desc.setExternalAllowed(oldAllowed); + desc.setExternalOnly(oldOnly); + desc.setStoreFactory(oldFactory); + } + } + + + /** + * A {@link StandardContextSF} that stores external context files into in-memory buffers instead of on disk, for the + * read-only store preview. Writing the (watched) context files would restart the running contexts, so during a + * preview they are only captured. + */ + private static final class CapturingContextSF extends StandardContextSF { + + private final Map captured = new LinkedHashMap<>(); + + public Map getCaptured() { + return captured; + } + + @Override + public void store(PrintWriter aWriter, int indent, Object aContext) throws Exception { + if (aContext instanceof StandardContext context && getRegistry() != null) { + StoreDescription desc = getRegistry().findDescription(context.getClass()); + if (desc != null && desc.isStoreSeparate() && desc.isExternalAllowed() && + context.getConfigFile() != null) { + // Capture the external context file in memory instead of + // writing it. The element is written inline (storeSeparate + // is temporarily off) so the separate-file branch of + // StandardContextSF.store is not re-entered. + StringWriter buffer = new StringWriter(); + PrintWriter w = new PrintWriter(buffer); + storeXMLHead(w); + boolean savedSeparate = desc.isStoreSeparate(); + desc.setStoreSeparate(false); + try { + super.store(w, -2, aContext); + } finally { + desc.setStoreSeparate(savedSeparate); + } + w.flush(); + captured.put(displayPath(context.getConfigFile()), buffer.toString()); + return; + } + } + super.store(aWriter, indent, aContext); + } + } + + + /** + * A context configuration file path for display: relative to the Catalina base where possible, otherwise the + * absolute path. + */ + private static String displayPath(URL configFile) { + try { + File file = new File(configFile.toURI()); + String base = Bootstrap.getCatalinaBase(); + if (base != null && !base.isEmpty()) { + String rel = new File(base).toPath().relativize(file.toPath()).toString(); + if (!rel.startsWith("..")) { + return rel.replace(File.separatorChar, '/'); + } + } + return file.getPath().replace(File.separatorChar, '/'); + } catch (Exception e) { + return String.valueOf(configFile); + } + } + + + // ------------------------------------------------------------ Helpers + + + private void requireServer() throws ConfigException { + if (server == null) { + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SERVER_UNAVAILABLE", + sm.getString("manager2.configServerUnavailable")); + } + } + + + private static String path(HttpServletRequest request) { + String path = request.getServletPath(); + String info = request.getPathInfo(); + if (info != null && !info.isEmpty()) { + path = path + info; + } + if (path == null || path.isEmpty()) { + path = "/api/config"; + } + return path; + } + + + private static Map readJson(HttpServletRequest request) throws IOException { + String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (body.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return new JSONParser(body).parseObject(); + } catch (Exception e) { + throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + } + } + + + private static String string(Object value) { + return value == null ? null : String.valueOf(value); + } + + + private static boolean bool(Object value, boolean defaultValue) { + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return Boolean.parseBoolean(s); + } + return defaultValue; + } + + + private static Integer intValue(Object value, int defaultValue) { + if (value instanceof Number n) { + return n.intValue(); + } + if (value instanceof String s && !s.isEmpty()) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + return defaultValue; + } + + + private static List stringList(Object value) { + List result = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + String s = String.valueOf(item).trim(); + if (!s.isEmpty()) { + result.add(s); + } + } + } else if (value instanceof String s && !s.isEmpty()) { + for (String part : s.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + } + return result; + } + + + private static String requiredName(Object value) throws ConfigException { + String name = string(value); + if (name == null || name.isEmpty() || !SAFE_NAME.matcher(name).matches()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.configInvalidName", name)); + } + return name; + } + + + private static String capitalize(String name) { + return name.substring(0, 1).toUpperCase() + name.substring(1); + } + + + /** + * Encode a value for use as a node id segment. The value is URL encoded and the {@code %2F} sequences (originating + * from {@code /} characters, e.g. in context paths) are replaced with a bare {@code +}, since Tomcat rejects + * {@code %2F} in the request URI. A literal {@code +} in the value is percent encoded, so the replacement is + * unambiguous. + */ + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("%2F", "+"); + } + + + /** + * Encode a context path for use as a node id segment. The root context (whose path is the empty string) is encoded + * as a bare {@code +}, the same segment that a path of {@code /} would produce, since {@link #dec(String)} turns a + * bare {@code +} back into a context path. + */ + private static String encContextPath(String path) { + if (path.isEmpty()) { + return "+"; + } + return enc(path); + } + + + /** + * The inverse of {@link #enc(String)}. + */ + private static String dec(String value) { + return URLDecoder.decode(value.replace("+", "%2F"), StandardCharsets.UTF_8); + } + + + /** + * A controlled failure of one API operation, carrying the HTTP status, the machine readable code and the (already + * localized) message. + */ + private static final class ConfigException extends Exception { + + @Serial + private static final long serialVersionUID = 1L; + + private final int status; + + private final String code; + + + ConfigException(int status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Constants.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Constants.java new file mode 100644 index 000000000000..8e0a1ade1e10 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Constants.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + + +/** + * Common constants for the Manager2 API. + */ +public final class Constants { + + + /** + * The name of this package. Used to obtain the StringManager instance. + */ + public static final String Package = "org.apache.tomcat.manager2"; + + + /** + * The character set used for all JSON responses. + */ + public static final String CHARSET = "UTF-8"; + + + /** + * The name of the request and response header that carries the CSRF token. + */ + public static final String CSRF_HEADER = "X-CSRF-Token"; + + + /** + * The session attribute that holds the current CSRF token. + */ + public static final String CSRF_TOKEN_SESSION_KEY = "org.apache.tomcat.manager2.CsrfFilter.token"; + + + private Constants() { + // Utility class, do not instantiate + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java new file mode 100644 index 000000000000..df948a6734c3 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.SecureRandom; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.res.StringManager; + + +/** + * CSRF protection filter for the Manager2 JSON API (synchronizer token pattern). + *

+ * A 128-bit random token is generated per session and exposed to the client in the {@code X-CSRF-Token} response header + * of every API response. State-changing requests (anything but GET, HEAD, OPTIONS and TRACE) must echo the token in the + * {@code X-CSRF-Token} request header. Requests without a valid token are rejected with a 403. + *

+ * As defence in depth, all mutation endpoints also require {@code Content-Type: application/json} (or a multipart + * upload for the WAR upload endpoint) and the web application sets no CORS headers, so cross-origin requests are + * additionally blocked by the browser same-origin policy. + */ +public class CsrfFilter implements Filter { + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + private static final SecureRandom RANDOM = new SecureRandom(); + + private static final int TOKEN_BYTES = 16; + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private static final String[] SAFE_METHODS = { "GET", "HEAD", "OPTIONS", "TRACE" }; + + + private final Log log = LogFactory.getLog(CsrfFilter.class); // must not be static + + + @Override + public void init(FilterConfig filterConfig) { + // Nothing to initialize + } + + + @Override + public void destroy() { + // Nothing to destroy + } + + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse resp = (HttpServletResponse) response; + + HttpSession session = req.getSession(); + String token = (String) session.getAttribute(Constants.CSRF_TOKEN_SESSION_KEY); + if (token == null) { + token = generateToken(); + session.setAttribute(Constants.CSRF_TOKEN_SESSION_KEY, token); + } + + // Expose the token to the client. Header must be set before the + // response is committed (i.e. before the servlet writes). + resp.setHeader(Constants.CSRF_HEADER, token); + + if (isSafeMethod(req.getMethod())) { + chain.doFilter(request, response); + return; + } + + String provided = req.getHeader(Constants.CSRF_HEADER); + if (provided == null || !constantTimeEquals(token, provided)) { + log(req, sm.getString("csrfFilter.invalid")); + Api.error(resp, HttpServletResponse.SC_FORBIDDEN, "CSRF", sm.getString("csrfFilter.invalid")); + return; + } + + chain.doFilter(request, response); + } + + + private static boolean isSafeMethod(String method) { + for (String safe : SAFE_METHODS) { + if (safe.equals(method)) { + return true; + } + } + return false; + } + + + private static boolean constantTimeEquals(String a, String b) { + byte[] ab = a.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] bb = b.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + return MessageDigest.isEqual(ab, bb); + } + + + private static String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + RANDOM.nextBytes(bytes); + char[] chars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + chars[i * 2] = HEX[(bytes[i] >> 4) & 0xF]; + chars[i * 2 + 1] = HEX[bytes[i] & 0xF]; + } + return new String(chars); + } + + + private void log(HttpServletRequest req, String message) { + log.info(message + " method=[" + req.getMethod() + "] uri=[" + req.getRequestURI() + "] remoteAddr=[" + + req.getRemoteAddr() + "] principal=[" + req.getUserPrincipal() + "]"); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ErrorServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ErrorServlet.java new file mode 100644 index 000000000000..829c134a249d --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ErrorServlet.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +/** + * Renders the 403 and 404 error pages. The pages are rendered from their templates (instead of being served as static + * resources) so that a {@code } element can be injected: error pages can be displayed at arbitrary URLs (the + * browser keeps the URL of the failed request) and the relative link to the CSS would otherwise break. + */ +public class ErrorServlet extends HttpServlet { + + + private static final long serialVersionUID = 1L; + + private static final String TEMPLATE_403 = "/error-403.html"; + private static final String TEMPLATE_404 = "/error-404.html"; + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + int status = HttpServletResponse.SC_NOT_FOUND; + Object errorStatus = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + if (errorStatus instanceof Integer code) { + status = code; + } + + String template = Html.readTemplate(getServletContext(), + status == HttpServletResponse.SC_FORBIDDEN ? TEMPLATE_403 : TEMPLATE_404); + if (template == null) { + return; // Let the container render the default error page. + } + + Html.render(request, response, template); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HeadersFilter.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HeadersFilter.java new file mode 100644 index 000000000000..dc14c51c0cd9 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HeadersFilter.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; + + +/** + * Adds the security headers that {@link org.apache.catalina.filters.HttpHeaderSecurityFilter} does not cover (a + * Content-Security-Policy and a Referrer-Policy) to every response. The web application uses no inline scripts, no + * external resources and no frames, so the policy does not need any 'unsafe-*' directives. + */ +public class HeadersFilter implements Filter { + + + private static final String CSP = "default-src 'self'; script-src 'self'; style-src 'self'; " + + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " + + "base-uri 'self'; form-action 'self'"; + + private static final String REFERRER_POLICY = "no-referrer"; + + + @Override + public void init(FilterConfig filterConfig) { + // Nothing to initialize + } + + + @Override + public void destroy() { + // Nothing to destroy + } + + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletResponse resp = (HttpServletResponse) response; + resp.setHeader("Content-Security-Policy", CSP); + resp.setHeader("Referrer-Policy", REFERRER_POLICY); + + chain.doFilter(request, response); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java new file mode 100644 index 000000000000..7afc8784c43d --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +/** + * Serves the SPA entry point and the SPA deep-link routes (e.g. {@code /apps}, {@code /hosts}, {@code /monitoring}, + * {@code /diagnostics}) which have no server-side resource of their own. + *

+ * Unauthenticated visitors are forwarded to the login page. Authenticated users are forwarded to the SPA shell + * ({@code /index.html}) so that the browser keeps the requested URL (deep links survive a reload). + *

+ * The SPA shell itself is deliberately not protected with a security constraint: a constraint on {@code /} + * would match every request in the context (including the CSS and JS that the login page needs) and would poison the + * FORM authentication "saved request" with asset URLs. Access control for the data is enforced by the constraints on + * {@code /api/*}. + */ +public class HomeServlet extends HttpServlet { + + + private static final long serialVersionUID = 1L; + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + if (request.getServletPath().isEmpty() && !request.getRequestURI().endsWith("/")) { + // The request targets the context root without a trailing slash + // (e.g. /manager2). Redirect to the trailing-slash form so that + // the browser resolves this application's relative URLs (CSS, + // JS, images) against the context instead of the server root. + // The mapper's own context root redirect + // (Context#setMapperContextRootRedirectEnabled) cannot do this: + // it only applies when no servlet is mapped to the context + // root, but this servlet is (via the empty URL pattern, which + // the mapper registers as the exact match "/"). + response.sendRedirect(request.getContextPath() + "/"); + return; + } + + if (request.getUserPrincipal() != null) { + request.getRequestDispatcher("/index.html").forward(request, response); + } else { + request.getRequestDispatcher("/login").forward(request, response); + } + } + + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java new file mode 100644 index 000000000000..247f7f44bdca --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java @@ -0,0 +1,254 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Serial; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.catalina.Container; +import org.apache.catalina.Host; +import org.apache.catalina.manager.host.HostManagerServlet; +import org.apache.tomcat.util.json.JSONParser; +import org.apache.tomcat.util.res.StringManager; + + +/** + * The Manager2 virtual host API. Delegates the actual operations to the protected methods of + * {@link HostManagerServlet}. + */ +public class HostsApiServlet extends HostManagerServlet { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + + @Override + public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + + String path = path(request); + + if (path.equals("/api/hosts")) { + Api.json(response, hosts()); + } else { + Api.notFound(response); + } + } + + + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + + String path = path(request); + + if (path.equals("/api/hosts")) { + try { + handleAdd(request, response); + } catch (IllegalArgumentException e) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); + } + } else if (path.equals("/api/hosts/persist")) { + sendResult(response, invoke(pw -> super.persist(pw, clientSm(request)))); + } else if (path.matches("/api/hosts/[^/]+/start")) { + sendResult(response, invoke(pw -> super.start(pw, nameOf(path), clientSm(request)))); + } else if (path.matches("/api/hosts/[^/]+/stop")) { + sendResult(response, invoke(pw -> super.stop(pw, nameOf(path), clientSm(request)))); + } else { + Api.notFound(response); + } + } + + + @Override + public void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException { + + String path = path(request); + + if (path.matches("/api/hosts/[^/]+")) { + String name = nameOf(path); + sendResult(response, invoke(pw -> super.remove(pw, name, clientSm(request)))); + } else { + Api.notFound(response); + } + } + + + private void handleAdd(HttpServletRequest request, HttpServletResponse response) throws IOException { + Map body = readJson(request); + + String name = string(body.get("name")); + String aliases = aliasesOf(body.get("aliases")); + String appBase = string(body.get("appBase")); + boolean manager = booleanValue(body.get("manager"), false); + boolean autoDeploy = booleanValue(body.get("autoDeploy"), true); + boolean deployOnStartup = booleanValue(body.get("deployOnStartup"), true); + boolean deployXML = booleanValue(body.get("deployXML"), true); + boolean unpackWARs = booleanValue(body.get("unpackWARs"), true); + boolean copyXML = booleanValue(body.get("copyXML"), false); + + sendResult(response, invoke(pw -> super.add(pw, name, aliases, appBase, manager, autoDeploy, deployOnStartup, + deployXML, unpackWARs, copyXML, clientSm(request)))); + } + + + private List> hosts() { + List> result = new ArrayList<>(); + Container[] children = engine.findChildren(); + for (Container child : children) { + Host h = (Host) child; + Map entry = new LinkedHashMap<>(); + entry.put("name", h.getName()); + entry.put("aliases", List.of(h.findAliases())); + entry.put("appBase", h.getAppBaseFile() != null ? h.getAppBaseFile().getPath() : null); + entry.put("state", h.getState().toString()); + // Whether the host is up and accepting applications: the same + // test the classic host manager uses for start/stop. + entry.put("started", h.getState().isAvailable()); + entry.put("self", Boolean.valueOf(h == installedHost)); + result.add(entry); + } + result.sort((a, b) -> String.valueOf(a.get("name")).compareTo(String.valueOf(b.get("name")))); + return result; + } + + + /** + * The servlet path plus the path info, so the mapping can be either a path mapping ({@code /api/hosts/*}) or an + * exact mapping. + */ + private static String path(HttpServletRequest request) { + String path = request.getServletPath(); + String info = request.getPathInfo(); + if (info != null && !info.isEmpty()) { + path = path + info; + } + if (path == null || path.isEmpty()) { + path = "/api/hosts"; + } + return path; + } + + + private static String nameOf(String path) { + String[] parts = path.split("/"); + // path is /api/hosts/{name} or /api/hosts/{name}/{action} + if (parts.length >= 3 && ("start".equals(parts[parts.length - 1]) || "stop".equals(parts[parts.length - 1]))) { + return parts[parts.length - 2]; + } + return parts[parts.length - 1]; + } + + + private static String string(Object value) { + return value == null ? null : String.valueOf(value); + } + + + /** + * Convert the {@code aliases} JSON value (array or comma separated string) to the comma separated string expected + * by {@link HostManagerServlet#add}. + */ + private static String aliasesOf(Object value) { + if (value instanceof List list) { + List parts = new ArrayList<>(); + for (Object item : list) { + parts.add(String.valueOf(item)); + } + return String.join(",", parts); + } + if (value instanceof String s && !s.isEmpty()) { + return s; + } + return null; + } + + + private static boolean booleanValue(Object value, boolean defaultValue) { + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return Boolean.parseBoolean(s); + } + return defaultValue; + } + + + private static Map readJson(HttpServletRequest request) throws IOException { + String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (body.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return new JSONParser(body).parseObject(); + } catch (Exception e) { + throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + } + } + + + /** + * Run one of the inherited text-based operations, capturing the result message. + */ + private String invoke(Operation operation) { + StringWriter stringWriter = new StringWriter(); + try (PrintWriter writer = new PrintWriter(stringWriter)) { + operation.run(writer); + } + return stringWriter.toString().trim(); + } + + + /** + * A StringManager for the legacy host manager message bundle. All the inherited operations report their results + * using the {@code org.apache.catalina.manager.host} strings. + */ + private static StringManager clientSm(HttpServletRequest request) { + return StringManager.getManager("org.apache.catalina.manager.host", request.getLocales()); + } + + + @FunctionalInterface + private interface Operation { + void run(PrintWriter writer); + } + + + private void sendResult(HttpServletResponse response, String message) throws IOException { + if (message.startsWith("FAIL -")) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "OPERATION_FAILED", message); + } else { + Api.ok(response, message); + } + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java new file mode 100644 index 000000000000..54605d966cab --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.ServletContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +/** + * Renders the static HTML pages that are served by a servlet (the login page and the error pages) instead of by the + * default servlet. The pages are rendered from their templates so that a {@code } element can be injected. The + * base element is required because these pages can be displayed at arbitrary URLs (the browser keeps the URL of the + * request that triggered the forward to the page), which would break the relative links to the CSS. + */ +final class Html { + + + /** + * Placeholder for the {@code } element in the HTML templates. It must be on a line of its own inside the + * {@code } element. + */ + static final String BASE_PLACEHOLDER = ""; + + + private Html() { + // Utility class + } + + + /** + * Read a template from the web application. + * + * @param context the servlet context + * @param path the context-relative template path, e.g. {@code /login.html} + * + * @return the template content, or {@code null} if it is missing + */ + static String readTemplate(ServletContext context, String path) { + try (InputStream is = context.getResourceAsStream(path)) { + if (is == null) { + return null; + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + return null; + } + } + + + /** + * Render a template: inject the base element and write the result to the response. + * + * @param request the current request (used for the context path) + * @param response the response to write + * @param template the template content + * + * @throws IOException if writing the response fails + */ + static void render(HttpServletRequest request, HttpServletResponse response, String template) throws IOException { + String html = template.replace(BASE_PLACEHOLDER, ""); + response.setContentType("text/html"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().print(html); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Json.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Json.java new file mode 100644 index 000000000000..793a5c45e98b --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Json.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.util.List; +import java.util.Map; + + +/** + * Minimal JSON writer used by the Manager2 API. The supported value types are {@code null}, {@link String}, + * {@link Number}, {@link Boolean}, {@link Map} and {@link List}. + */ +public final class Json { + + + /** + * Serialize the given value to JSON. + * + * @param value the value to serialize + * + * @return the JSON representation + */ + public static String write(Object value) { + StringBuilder result = new StringBuilder(128); + writeValue(result, value); + return result.toString(); + } + + + /** + * Escape a string for inclusion in a JSON string literal (quotes and backslashes only; the value is written by the + * caller between double quotes). + * + * @param value the value to escape + * + * @return the escaped value + */ + public static String escape(String value) { + StringBuilder result = new StringBuilder(value.length() + 8); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> result.append("\\\""); + case '\\' -> result.append("\\\\"); + case '\b' -> result.append("\\b"); + case '\f' -> result.append("\\f"); + case '\n' -> result.append("\\n"); + case '\r' -> result.append("\\r"); + case '\t' -> result.append("\\t"); + default -> { + if (c < 0x20) { + result.append(String.format("\\u%04x", (int) c)); + } else { + result.append(c); + } + } + } + } + return result.toString(); + } + + + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + sb.append('"').append(escape(s)).append('"'); + } else if (value instanceof Boolean b) { + sb.append(b); + } else if (value instanceof Number n) { + // Render integral numbers without a fractional part + if (n instanceof Double d && d == Math.floor(d) && !Double.isInfinite(d)) { + sb.append(n.longValue()); + } else { + sb.append(n); + } + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + sb.append('"').append(escape(String.valueOf(entry.getKey()))).append("\":"); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else { + // Fallback: treat as string + sb.append('"').append(escape(String.valueOf(value))).append('"'); + } + } + + + private Json() { + // Utility class, do not instantiate + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties new file mode 100644 index 000000000000..3dc038168fd7 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -0,0 +1,108 @@ +# ----------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# ----------------------------------------------------------------------------- + +csrfFilter.invalid=CSRF token missing or invalid. Reload the page and try again. + +manager2.error.jmx=Error querying the MBean server +manager2.error.status=Error collecting runtime status +manager2.error.upload=Error during WAR upload +manager2.history.invalidParameter=The init parameter [{0}] of the status API servlet has an invalid value [{1}]. The default value will be used. +manager2.error.logs=Error reading a log file +manager2.logsDirMissing=The logs directory of this Tomcat instance could not be found. +manager2.invalidLogName=The log file name is missing or invalid. + +manager2.error.users=Error managing the user database +manager2.userDatabaseMissing=No user database is configured for this server. Add a UserDatabase JNDI resource (for example the file based one from the default server.xml) to manage users, groups and roles. +manager2.userDatabaseLookup=Error looking up the JNDI resource [{0}] +manager2.userDatabaseNotFound=The user database [{0}] is not configured for this server. +manager2.userDatabaseReadonly=The user database [{0}] is read-only. Add readonly="false" to its Resource definition in server.xml and restart the server to allow changes. +manager2.userDatabaseNotWritable=The user database [{0}] cannot be persisted: its storage location is not writable. +manager2.userDatabaseSaveFailed=The changes could not be saved to the user database: {0} +manager2.usersSaved=The changes were saved to the user database. +manager2.userExists=The user [{0}] already exists. +manager2.groupExists=The group [{0}] already exists. +manager2.roleExists=The role [{0}] already exists. +manager2.userNotFound=The user [{0}] was not found in the user database. +manager2.groupNotFound=The group [{0}] was not found in the user database. +manager2.roleNotFound=The role [{0}] was not found in the user database. +manager2.selfRemoval=The user cannot be removed while signed in as that user. +manager2.selfRoleRemoval=The role [{0}] cannot be removed while signed in as a user that holds it, directly or through a group. +manager2.unknownGroupMember=Unknown user(s) in the group members: {0} +manager2.usernameMissing=The user name [{0}] is missing or invalid. +manager2.groupnameMissing=The group name [{0}] is missing or invalid. +manager2.rolenameMissing=The role name [{0}] is missing or invalid. +manager2.passwordMissing=The password is missing. + +manager2.invalidJson=Invalid JSON in request body: {0} +manager2.missingPath=The context path was not specified or is invalid. +manager2.sessionNotFound=Session [{0}] was not found. +manager2.attributeRemoved=Session attribute [{0}] removed. +manager2.attributeNotFound=The session did not contain any attribute named [{0}]. +manager2.pathCheckFail=Invalid target path [{0}] for the host appBase [{1}]. +manager2.pathCheckError=Error checking the target path [{0}] for the host appBase [{1}]: {2} + +manager2.error.config=Error during a configuration operation +manager2.configServerUnavailable=The server component could not be determined. +manager2.configInvalidId=The component id is missing or invalid. +manager2.configNotFound=The component was not found. +manager2.configAttributeNotFound=The attribute [{0}] is not defined for this component. +manager2.configReadOnly=The attribute [{0}] is read-only. +manager2.configInvalidValue=The value for the attribute [{0}] is not valid. +manager2.configInvalidName=The component name [{0}] is missing or invalid. +manager2.configInvalidPath=The path [{0}] is missing or invalid. +manager2.configInvalidClass=The component class [{0}] could not be instantiated. +manager2.configNoSetter=No setter is available for the attribute (method [{0}]). +manager2.configSetFailed=The attribute [{0}] could not be updated: {1} +manager2.configAttributeUpdated=Attribute [{0}] of [{1}] has been updated. +manager2.configConfirmRequired=This operation requires confirmation. Type [{0}] to confirm. +manager2.configRequiredComponent=The {0} of a context is required and cannot be removed. Replace it with a different implementation instead. +manager2.configRequiredNamingResources=The JNDI naming resources cannot be removed. +manager2.configContextMustBeStopped=The resources of a running context cannot be replaced. Stop the context [{0}] first. +manager2.configSelfComponent=This component hosts the manager web application itself and cannot be renamed or removed. +manager2.configBasicValve=The basic valve of a container cannot be removed. +manager2.configLastRealm=The realm of this container cannot be removed: the container would be left without a realm. Add a replacement realm first. +manager2.configLastService=The last service of the server cannot be removed. +manager2.configMaxNestedRealms=A realm can be nested at most 3 levels deep. +manager2.configNotEmpty=The {0} still contains components and cannot be removed. +manager2.configRemoveNotSupported=A {0} cannot be removed from a running cluster. Remove the cluster itself and re-add it, or edit server.xml directly. +manager2.configDuplicate=A component named [{0}] already exists. +manager2.configParentInvalid=The parent component does not accept a child of type [{0}]. +manager2.configTypeUnsupported=The component type [{0}] is not supported. +manager2.configAdded=The component [{0}] has been added. +manager2.configStartFailed=The component [{0}] could not be started: {1} +manager2.configAddFailed=The component [{0}] could not be added: {1} +manager2.configRollbackFailed=The failed component [{0}] could not be rolled back: {1} +manager2.configExecutorInvalid=The executor settings are invalid: minSpareThreads must be less than or equal to maxThreads. +manager2.configDocBaseMissing=The document base [{0}] of the context does not exist and could not be created. +manager2.configRemoveFailed=The component could not be removed: {0} +manager2.configRemoved=The component [{0}] has been removed. +manager2.configJndiTypeRequired=The JNDI type (jndiType) is required for a {0}. +manager2.configJndiGlobalRequired=The JNDI name of the global resource (global) is required for a resourceLink. +manager2.configJndiNameTaken=The JNDI name [{0}] is already in use. +manager2.configInvalidType=not a valid {0} +manager2.configFactoryInvalid=The JNDI factory class [{0}] could not be loaded. +manager2.configSslUnsupported=The protocol handler [{0}] of this connector does not support TLS. +manager2.configSslCertificateRequired=A running connector cannot be switched to TLS without a certificate. Provide the certificate together with the SSL host configuration. +manager2.configSslDefault=The default SSL host configuration of a running, TLS enabled connector cannot be removed while other configurations remain. +manager2.configSslLastCertificate=The last certificate of the running, TLS enabled connector [{0}] cannot be removed. Stop the connector first. +manager2.configSslReloadFailed=The attribute [{0}] of [{1}] was reverted: the TLS configuration of the connector does not accept it: {2} +manager2.configStoreFailed=The configuration could not be written to server.xml. +manager2.configStored=The configuration has been written to {0}. Backup: {1} +manager2.configAuditAttribute=Config: set attribute [{0}] of [{1}] to [{2}] +manager2.configAuditAdd=Config: added {0} [{1}] +manager2.configAuditRemove=Config: removed {0} [{1}] +manager2.configAuditStore=Config: stored configuration to conf/server.xml (backup: {0}) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java new file mode 100644 index 000000000000..10e1fb09a5c4 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java @@ -0,0 +1,292 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.tomcat.util.json.JSONParser; + + +/** + * Parsers for the two log families displayed by manager2. + *

+ * Server logs are JULI files: one record per line, either in the plain one-line format + * ({@code org.apache.juli.OneLineFormatter}: {@code 13-Sep-2026 14:53:07.449 INFO [main] org.apache... Message}) or the + * JSON format ({@code org.apache.juli.JsonFormatter}: + * {@code {"time":"...","level":"INFO","thread":"main","class":"...","method":"...","message":"..."}}). In the plain + * format, the stack trace of a record continues on the following lines. + *

+ * Access logs are written by {@code org.apache.catalina.valves.AccessLogValve} (pattern based) or + * {@code org.apache.catalina.valves.JsonAccessLogValve} (one JSON object per line, one attribute per pattern element). + * For the pattern based format the valve's pattern is converted to a regular expression, so the fields that are + * available (and therefore filterable) depend on the configured pattern. + */ +public final class LogParser { + + + /** + * A compiled access log pattern: the regular expression, the field names of the capture groups and the ordered list + * of all field names a record can have (including the fields derived from the request line). + */ + public static final class AccessParser { + + private final Pattern regex; + + private final List groupFields; + + private final List fields; + + AccessParser(Pattern regex, List groupFields, List fields) { + this.regex = regex; + this.groupFields = groupFields; + this.fields = fields; + } + + + /** + * The ordered list of field names a record of this format can have. + */ + public List getFields() { + return fields; + } + + + /** + * Parse one access log line. + * + * @param line the line to parse + * + * @return the parsed record (field names as keys) or {@code null} if the line does not match the pattern + */ + public Map parse(String line) { + Matcher m = regex.matcher(line); + if (!m.matches()) { + return null; + } + Map record = new LinkedHashMap<>(); + for (int i = 0; i < groupFields.size(); i++) { + String value = m.group(i + 1); + if (value != null) { + record.put(groupFields.get(i), value); + } + } + AccessLogSupport.normalize(record); + AccessLogSupport.deriveFromRequest(record); + return record; + } + } + + + /** + * The single line format of the JULI one line log formatter: + * {@code dd-MMM-yyyy HH:mm:ss.SSS LEVEL [thread] source.message} + */ + private static final Pattern TEXT_LOG_LINE = Pattern.compile( + "^(\\d{2}-[A-Za-z]{3}-\\d{4} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (\\S+)" + " \\[([^\\]]*)\\] (\\S+) (.*)$"); + + + /** + * Parse one line of a plain text JULI log file. + * + * @param line the line to parse + * + * @return a record with the fields {@code time}, {@code level}, {@code thread}, {@code source} and {@code message} + * or {@code null} if the line is not a record (for example a continuation line of a stack trace) + */ + public static Map parseTextLogLine(String line) { + Matcher m = TEXT_LOG_LINE.matcher(line); + if (!m.matches()) { + return null; + } + Map record = new LinkedHashMap<>(); + record.put("time", m.group(1)); + record.put("level", m.group(2)); + record.put("thread", m.group(3)); + record.put("source", m.group(4)); + record.put("message", m.group(5)); + return record; + } + + + /** + * Parse one line of a JSON JULI log file ({@code org.apache.juli.JsonFormatter} output). + * + * @param line the line to parse + * + * @return a record with the fields {@code time}, {@code level}, {@code thread}, {@code source}, {@code message} + * and, when the record has an exception, {@code throwable} (a list of strings); or {@code null} if the + * line is not a JSON object + */ + public static Map parseJsonLogLine(String line) { + if (line.isEmpty() || line.charAt(0) != '{') { + return null; + } + try { + Object parsed = new JSONParser(line).parseObject(); + if (!(parsed instanceof Map obj)) { + return null; + } + Map record = new LinkedHashMap<>(); + record.put("time", stringOf(obj.get("time"))); + record.put("level", stringOf(obj.get("level"))); + record.put("thread", stringOf(obj.get("thread"))); + String className = stringOf(obj.get("class")); + String methodName = stringOf(obj.get("method")); + if (className != null || methodName != null) { + StringBuilder source = new StringBuilder(); + if (className != null) { + source.append(className); + } + if (className != null && methodName != null) { + source.append('.'); + } + if (methodName != null) { + source.append(methodName); + } + record.put("source", source.toString()); + } + record.put("message", stringOf(obj.get("message"))); + if (obj.get("throwable") instanceof List thrown && !thrown.isEmpty()) { + record.put("throwable", thrown); + } + return record; + } catch (Exception e) { + return null; + } + } + + + /** + * Parse one line of a JSON access log ({@code org.apache.catalina.valves.JsonAccessLogValve} output). + * + * @param line the line to parse + * + * @return the parsed record (the attribute names of the JSON object as keys) or {@code null} if the line is not a + * JSON object + */ + public static Map parseJsonAccessLine(String line) { + if (line.isEmpty() || line.charAt(0) != '{') { + return null; + } + try { + Object parsed = new JSONParser(line).parseObject(); + if (!(parsed instanceof Map obj)) { + return null; + } + Map record = new LinkedHashMap<>(); + for (Map.Entry entry : obj.entrySet()) { + record.put(String.valueOf(entry.getKey()), entry.getValue()); + } + AccessLogSupport.normalize(record); + AccessLogSupport.deriveFromRequest(record); + return record; + } catch (Exception e) { + return null; + } + } + + + private static String stringOf(Object value) { + if (value == null) { + return null; + } + return value instanceof String s ? s : String.valueOf(value); + } + + + /** + * Compile an access log pattern into a line parser. The directive to field name mapping is the one of + * {@code org.apache.catalina.valves.JsonAccessLogValve}, so that both access log formats expose the same field + * names. + * + * @param pattern the access log pattern (for example {@code %h %l %u %t "%r" %s %b}) + * + * @return the compiled parser + * + * @throws IllegalArgumentException if the pattern uses an unsupported directive or produces a regular expression + * with more than the maximum number of capture groups + */ + public static AccessParser compile(String pattern) { + StringBuilder regex = new StringBuilder(pattern.length() * 2); + List groupFields = new ArrayList<>(); + StringBuilder literal = new StringBuilder(); + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c != '%') { + literal.append(c); + continue; + } + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + literal.setLength(0); + } + if (i + 1 >= pattern.length()) { + throw new IllegalArgumentException("Dangling '%' in access log pattern: " + pattern); + } + char next = pattern.charAt(++i); + if (next == '%') { + literal.append('%'); + continue; + } + if (next == '{') { + int close = pattern.indexOf('}', i + 1); + if (close < 0 || close + 1 >= pattern.length()) { + throw new IllegalArgumentException("Malformed access log pattern: " + pattern); + } + String key = pattern.substring(i + 1, close); + char directive = pattern.charAt(close + 1); + String field = AccessLogSupport.keyedField(directive, key); + if (field == null) { + throw new IllegalArgumentException( + "Unsupported directive %{%s}%c in access log pattern".formatted(key, directive)); + } + regex.append("([^\\\"]*)"); + groupFields.add(field); + i = close + 1; + continue; + } + String[] directive = AccessLogSupport.directives().get(next); + if (directive == null) { + throw new IllegalArgumentException("Unsupported directive %c in access log pattern".formatted(next)); + } + regex.append(directive[0]); + groupFields.add(directive[1]); + } + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + } + + Pattern regexPattern; + try { + regexPattern = Pattern.compile(regex.toString()); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Invalid access log pattern: " + pattern, e); + } + + return new AccessParser(regexPattern, groupFields, AccessLogSupport.displayFields(groupFields)); + } + + + private LogParser() { + // Utility class, do not instantiate + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java new file mode 100644 index 000000000000..87d0d4766c46 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.lang.reflect.Field; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.catalina.Context; +import org.apache.catalina.Session; +import org.apache.catalina.Valve; +import org.apache.catalina.authenticator.Constants; +import org.apache.catalina.authenticator.FormAuthenticator; +import org.apache.catalina.authenticator.SavedRequest; +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.RequestFacade; + + +/** + * Renders the login page (the FORM authentication {@code form-login-page}) and makes sure that a successful login + * always sends the browser back to the SPA root. + *

+ * FORM authentication remembers the request that triggered the redirect to the login page (the "saved request") and, + * after a successful login, redirects the browser to it. That saved request is whatever the browser requested last + * while unauthenticated - which, for a single-page application, is frequently a CSS or JS file or a JSON API call. To + * keep the post-login redirect pointing at the application, the saved request is replaced with a GET of the context + * root ({@code /}) every time the login page is rendered. + *

+ * The {@code landingPage} of the {@link FormAuthenticator} is set as well, as a safety net for logins where no request + * was saved at all. + */ +public class LoginServlet extends HttpServlet { + + + private static final long serialVersionUID = 1L; + + private static final String TEMPLATE = "/login.html"; + private static final String APP_ROOT = "/"; + + private volatile boolean landingConfigured = false; + + // The servlet request chain ends in a RequestFacade (which wraps the + // container Request but is not an HttpServletRequestWrapper). The + // container Request is only reachable through this protected field, so + // it is resolved once via reflection. + private static final Field REQUEST_FACADE_REQUEST_FIELD = resolveRequestFacadeField(); + + private static Field resolveRequestFacadeField() { + try { + Field field = RequestFacade.class.getDeclaredField("request"); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + return null; + } + } + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + renderLogin(request, response); + } + + + /** + * The form-error-page forward (after a failed login) keeps the POST method of the login form submission, so the + * login page is rendered for POST requests as well. + */ + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + renderLogin(request, response); + } + + + private void renderLogin(HttpServletRequest request, HttpServletResponse response) throws IOException { + + Request containerRequest = unwrap(request); + + if (containerRequest != null) { + pointSavedRequestAtAppRoot(containerRequest); + if (!landingConfigured) { + setLandingPage(containerRequest); + } + } + + String template = Html.readTemplate(getServletContext(), TEMPLATE); + if (template == null) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Login page template missing"); + return; + } + if (request.getParameter("error") != null) { + template = template.replace("id=\"login-error\" hidden", "id=\"login-error\""); + } + Html.render(request, response, template); + } + + + /** + * Normalize the FORM authentication saved request, if any, so that the post-login redirect lands in the + * application. + *

+ * When this servlet renders the login page on behalf of HomeServlet for an SPA route (the user's browser was on, + * e.g., the Applications page when the session expired and re-navigated to that page, which the server then gated), + * the forwarded request still carries the route URI and the saved request is pointed at that route, so that a + * successful login returns the user to exactly the page they were on. + *

+ * Otherwise, a saved request that already points at an SPA route is kept as-is and any other saved request (an API + * call, a static asset, ...) is replaced with a GET of the SPA root. + *

+ * A session is created (if the browser does not have one yet) so that the session cookie is issued with the login + * page and the subsequent submission of the login form is tied to this session. + */ + private void pointSavedRequestAtAppRoot(Request request) { + Session session = request.getSessionInternal(true); + // Record the current session ID. FormAuthenticator verifies the + // session ID against this note during login and expires the session + // when the note is missing (it is normally written when the + // authenticator changes the session ID while forwarding to the + // login page, which does not happen when this servlet renders the + // login page on behalf of HomeServlet). + session.setNote(Constants.SESSION_ID_NOTE, session.getIdInternal()); + + String contextPath = request.getContextPath(); + + // This login page was rendered for a page route (deep-link gate by + // HomeServlet): point the saved request at that route so that a + // successful login lands the user back on that page. + String requestUri = request.getRequestURI(); + if (requestUri.startsWith(contextPath) && isSpaRoute(requestUri.substring(contextPath.length()))) { + session.setNote(Constants.FORM_REQUEST_NOTE, savedRequest(requestUri)); + return; + } + + Object note = session.getNote(Constants.FORM_REQUEST_NOTE); + if (note instanceof SavedRequest saved && "GET".equals(saved.getMethod())) { + String uri = saved.getRequestURI(); + if (uri != null && uri.startsWith(contextPath) && isSpaRoute(uri.substring(contextPath.length()))) { + // Keep the saved request: it points at a page of this + // application, so restoring it after login lands the user + // back on that page. + return; + } + } + + session.setNote(Constants.FORM_REQUEST_NOTE, savedRequest(contextPath + APP_ROOT)); + } + + + private static SavedRequest savedRequest(String uri) { + SavedRequest saved = new SavedRequest(); + saved.setMethod("GET"); + saved.setRequestURI(uri); + saved.setDecodedRequestURI(uri); + return saved; + } + + + /** + * The routes that the SPA renders client-side and that HomeServlet serves as the application shell. + */ + private static boolean isSpaRoute(String path) { + if (path == null) { + return false; + } + if (path.isEmpty() || "/".equals(path)) { + return true; + } + return "/apps".equals(path) || path.startsWith("/apps/") || "/hosts".equals(path) || + "/configuration".equals(path) || "/monitoring".equals(path) || "/diagnostics".equals(path) || + "/logs".equals(path) || "/access-log".equals(path) || "/users".equals(path); + } + + + /** + * Set the landing page of the FORM authenticator so that a successful login without a saved request still ends up + * at the SPA root instead of a 400. + */ + private void setLandingPage(Request request) { + synchronized (this) { + if (landingConfigured) { + return; + } + Context context = request.getContext(); + if (context != null) { + for (Valve valve : context.getPipeline().getValves()) { + if (valve instanceof FormAuthenticator formAuthenticator && + formAuthenticator.getLandingPage() == null) { + formAuthenticator.setLandingPage(APP_ROOT); + } + } + } + landingConfigured = true; + } + } + + + /** + * Unwrap the servlet request wrappers to reach the container request. + * + * @param request the servlet request + * + * @return the container request, or {@code null} if the chain does not end in one (should not happen in a standard + * Tomcat deployment) + */ + private static Request unwrap(HttpServletRequest request) { + HttpServletRequest current = request; + int depth = 0; + while (current instanceof HttpServletRequestWrapper wrapper) { + current = (HttpServletRequest) wrapper.getRequest(); + if (++depth > 10) { + return null; + } + } + if (current instanceof Request containerRequest) { + return containerRequest; + } + if (current instanceof RequestFacade && REQUEST_FACADE_REQUEST_FIELD != null) { + try { + return (Request) REQUEST_FACADE_REQUEST_FIELD.get(current); + } catch (IllegalAccessException e) { + return null; + } + } + return null; + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogoutServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogoutServlet.java new file mode 100644 index 000000000000..e27f17b4b0dd --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogoutServlet.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + + +/** + * Logs the user out by invalidating the session and sends the browser back to the application root (which, now that the + * session is gone, shows the login page). + */ +public class LogoutServlet extends HttpServlet { + + + private static final long serialVersionUID = 1L; + + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + response.sendRedirect(request.getContextPath() + "/", HttpServletResponse.SC_SEE_OTHER); + } + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + doPost(request, response); + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java new file mode 100644 index 000000000000..87b0c8cf884f --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java @@ -0,0 +1,756 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.io.Serial; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.catalina.ContainerServlet; +import org.apache.catalina.Context; +import org.apache.catalina.Host; +import org.apache.catalina.Wrapper; +import org.apache.catalina.Container; +import org.apache.catalina.Valve; +import org.apache.catalina.valves.AbstractAccessLogValve; +import org.apache.tomcat.util.res.StringManager; + + +/** + * The Manager2 log API. Serves the list of server log files (JULI) and of access log files, and a filtered tail of one + * file. Only the most recent part of a file is read (everything older is discarded), and the records of a response are + * ordered from most recent to least recent. + *

+ * Both log families are handled in their plain text and their JSON format (the JSON format is used when a log + * handler or the access log valve is configured with the JSON formatter/valve). For the access log the pattern based + * format is parsed with the pattern of the configured {@code AccessLogValve} (falling back to the common and combined + * patterns), so the available fields - and therefore the available filters - depend on the configured pattern. + *

+ * Like the rest of the API that is not part of the read-only status endpoints, this API requires the + * {@code manager-gui} role. + */ +public class LogsApiServlet extends HttpServlet implements ContainerServlet { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + + /** + * The maximum number of records returned by one request. + */ + private static final int MAX_LINES = 5000; + + /** + * The default number of records returned by one request. + */ + private static final int DEFAULT_LINES = 500; + + /** + * The maximum number of bytes read from the end of one file. + */ + private static final long READ_CAP = 16L * 1024 * 1024; + + /** + * The number of bytes read from the start of a file to detect its format. + */ + private static final int FORMAT_CAP = 64 * 1024; + + /** + * Server log file names: the JULI log files of the default {@code logging.properties} (including the console + * output). + */ + private static final Pattern SERVER_LOG_NAME = Pattern + .compile("^(catalina|localhost|manager|host-manager)(\\.\\d{4}-\\d{2}-\\d{2})?\\.(log|out|err)$"); + + /** + * Access log file names: the files written by the access log valve ({@code [host]_access_log.date.suffix} by + * default). + */ + private static final Pattern ACCESS_LOG_NAME = Pattern.compile(".*access_log.*\\.(txt|log)$"); + + /** + * A file name that may be used in the {@code name} request parameter. + */ + private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+"); + + /** + * The canonical order of the log levels in the level counts. + */ + private static final String[] LEVEL_ORDER = { "SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST" }; + + + private transient Wrapper wrapper = null; + + private transient Context context = null; + + private transient Host host = null; + + + // ------------------------------------------------ ContainerServlet API + + + @Override + public Wrapper getWrapper() { + return wrapper; + } + + + @Override + public void setWrapper(Wrapper wrapper) { + this.wrapper = wrapper; + if (wrapper == null) { + context = null; + host = null; + } else { + context = (Context) wrapper.getParent(); + host = (Host) context.getParent(); + } + } + + + // ------------------------------------------------------------ Request API + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = request.getServletPath(); + String info = request.getPathInfo(); + if (info != null && !info.isEmpty()) { + path = path + info; + } + + try { + if ("/api/logs".equals(path)) { + list(response, false); + } else if ("/api/logs/file".equals(path)) { + read(response, false, request); + } else if ("/api/access-log".equals(path)) { + list(response, true); + } else if ("/api/access-log/file".equals(path)) { + read(response, true, request); + } else { + Api.notFound(response); + } + } catch (Exception e) { + log(sm.getString("manager2.error.logs"), e); + throw new ServletException(e); + } + } + + + private void list(HttpServletResponse response, boolean access) throws IOException { + + File dir = logsDirectory(); + if (dir == null) { + Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", + sm.getString("manager2.logsDirMissing")); + return; + } + + List> files = new ArrayList<>(); + File[] children = dir.listFiles(); + if (children != null) { + for (File file : children) { + if (!file.isFile()) { + continue; + } + String name = file.getName(); + if (access) { + if (!ACCESS_LOG_NAME.matcher(name).matches()) { + continue; + } + } else if (!SERVER_LOG_NAME.matcher(name).matches()) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", name); + entry.put("size", Long.valueOf(file.length())); + entry.put("modified", Long.valueOf(file.lastModified())); + String format = detectFormat(file); + entry.put("format", format); + if (access) { + String pattern = findPattern(file, format); + entry.put("pattern", pattern); + entry.put("fields", findFields(file, format, pattern)); + } + files.add(entry); + } + } + files.sort((a, b) -> { + int byModified = ((Number) b.get("modified")).longValue() > ((Number) a.get("modified")).longValue() ? 1 + : -1; + if (byModified != 0) { + return byModified; + } + return String.valueOf(a.get("name")).compareTo(String.valueOf(b.get("name"))); + }); + + Map payload = new LinkedHashMap<>(); + payload.put("logs", files); + Api.json(response, payload); + } + + + private void read(HttpServletResponse response, boolean access, HttpServletRequest request) throws IOException { + + File dir = logsDirectory(); + if (dir == null) { + Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", + sm.getString("manager2.logsDirMissing")); + return; + } + + String name = request.getParameter("name"); + if (name == null || !SAFE_NAME.matcher(name).matches()) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.invalidLogName")); + return; + } + File file = new File(dir, name); + try { + if (!file.getCanonicalFile().toPath().startsWith(dir.getCanonicalFile().toPath()) || !file.isFile()) { + Api.notFound(response); + return; + } + } catch (IOException e) { + Api.notFound(response); + return; + } + + int lines; + try { + lines = Integer.parseInt(request.getParameter("lines")); + } catch (Exception e) { + lines = DEFAULT_LINES; + } + lines = Math.max(1, Math.min(MAX_LINES, lines)); + + byte[] data = readTail(file); + boolean truncated = file.length() > data.length; + List rawLines = new ArrayList<>(); + for (String line : new String(data, StandardCharsets.UTF_8).split("\\r?\\n", -1)) { + if (!line.isEmpty()) { + rawLines.add(line); + } + } + + Map payload; + if (access) { + payload = readAccessLog(file, rawLines, lines, truncated, param(request, "method"), + param(request, "status"), param(request, "user"), param(request, "session"), + param(request, "search")); + } else { + payload = readServerLog(file, rawLines, lines, truncated, param(request, "level"), + param(request, "search")); + } + Api.json(response, payload); + } + + + // --------------------------------------------------------- Server logs + + + private Map readServerLog(File file, List rawLines, int lines, boolean truncated, + String level, String search) { + + String format = detectFormat(file); + List> records = new ArrayList<>(); + Map levels = new LinkedHashMap<>(); + Map previous = null; + for (String line : rawLines) { + Map record = "json".equals(format) ? LogParser.parseJsonLogLine(line) + : LogParser.parseTextLogLine(line); + if (record == null) { + // Continuation line of a stack trace (plain format) or an + // unparsable line. + if (previous != null && !previous.containsKey("raw")) { + Object throwable = previous.get("throwable"); + if (throwable instanceof String t) { + previous.put("throwable", t + "\n" + line); + } else if (throwable == null) { + previous.put("throwable", line); + } + continue; + } + if (previous != null && previous.containsKey("raw")) { + previous.put("raw", previous.get("raw") + "\n" + line); + continue; + } + record = new LinkedHashMap<>(); + record.put("raw", line); + records.add(record); + previous = record; + continue; + } + String recordLevel = String.valueOf(record.get("level")); + if (!"null".equals(recordLevel)) { + levels.merge(recordLevel, 1, Integer::sum); + } + records.add(record); + previous = record; + } + + for (String levelName : LEVEL_ORDER) { + Integer count = levels.remove(levelName); + if (count != null) { + levels.put(levelName, count); + } + } + + List> matched = filter(records, level, search); + + Map payload = new LinkedHashMap<>(); + payload.put("name", file.getName()); + payload.put("format", format); + payload.put("fields", List.of("time", "level", "thread", "source", "message")); + payload.put("levels", levels); + payload.put("total", Long.valueOf(records.size())); + payload.put("matched", Long.valueOf(matched.size())); + payload.put("readBytes", Long.valueOf(estimatedBytes(rawLines))); + payload.put("totalBytes", Long.valueOf(file.length())); + payload.put("truncated", Boolean.valueOf(truncated)); + payload.put("records", tail(matched, lines)); + return payload; + } + + + // ---------------------------------------------------------- Access logs + + + private Map readAccessLog(File file, List rawLines, int lines, boolean truncated, + String method, String status, String user, String session, String search) { + + String format = detectFormat(file); + LogParser.AccessParser parser = null; + String pattern = null; + if (!"json".equals(format)) { + pattern = findPattern(file, format); + if (pattern != null) { + try { + parser = LogParser.compile(pattern); + } catch (IllegalArgumentException e) { + parser = null; + } + } + } + + List> records = new ArrayList<>(); + Map statusClasses = new LinkedHashMap<>(); + for (String bucket : new String[] { "1xx", "2xx", "3xx", "4xx", "5xx", "other" }) { + statusClasses.put(bucket, 0); + } + Map methodCounts = new LinkedHashMap<>(); + for (String line : rawLines) { + Map record = "json".equals(format) ? LogParser.parseJsonAccessLine(line) + : (parser != null ? parser.parse(line) : null); + if (record == null) { + record = new LinkedHashMap<>(); + record.put("raw", line); + } + Object statusCode = record.get("statusCode"); + if (statusCode instanceof Number n) { + int value = n.intValue(); + if (value >= 100 && value <= 599) { + statusClasses.merge((value / 100) + "xx", 1, Integer::sum); + } else { + statusClasses.merge("other", 1, Integer::sum); + } + } + if (record.get("method") instanceof String m) { + methodCounts.merge(m, 1, Integer::sum); + } + records.add(record); + } + + List> matched = filter(records, method, status, user, session, search); + + List> methods = new ArrayList<>(); + methodCounts.entrySet().stream().sorted((a, b) -> b.getValue() - a.getValue()).limit(20).forEach(entry -> { + Map item = new LinkedHashMap<>(); + item.put("name", entry.getKey()); + item.put("count", entry.getValue()); + methods.add(item); + }); + + Map payload = new LinkedHashMap<>(); + payload.put("name", file.getName()); + payload.put("format", format); + payload.put("pattern", pattern); + payload.put("fields", findFields(file, format, pattern)); + payload.put("statusClasses", statusClasses); + payload.put("methods", methods); + payload.put("total", Long.valueOf(records.size())); + payload.put("matched", Long.valueOf(matched.size())); + payload.put("readBytes", Long.valueOf(estimatedBytes(rawLines))); + payload.put("totalBytes", Long.valueOf(file.length())); + payload.put("truncated", Boolean.valueOf(truncated)); + payload.put("records", tail(matched, lines)); + return payload; + } + + + // -------------------------------------------------------------- Filters + + + private static List> filter(List> records, String level, String search) { + + if ((level == null || level.isEmpty()) && (search == null || search.isEmpty())) { + return records; + } + List> result = new ArrayList<>(); + for (Map record : records) { + if (level != null && !level.isEmpty()) { + Object recordLevel = record.get("level"); + if (!(recordLevel instanceof String l) || !l.equalsIgnoreCase(level)) { + continue; + } + } + if (search != null && !search.isEmpty() && !containsSearch(record, search)) { + continue; + } + result.add(record); + } + return result; + } + + + private static List> filter(List> records, String method, String status, + String user, String session, String search) { + + if (method == null && status == null && user == null && session == null && search == null) { + return records; + } + List> result = new ArrayList<>(); + for (Map record : records) { + if (method != null && !method.isEmpty()) { + Object recordMethod = record.get("method"); + if (!(recordMethod instanceof String m) || !m.equalsIgnoreCase(method)) { + continue; + } + } + if (status != null && !status.isEmpty() && !statusMatches(record, status)) { + continue; + } + if (user != null && !user.isEmpty()) { + if (!fieldMatches(record, new String[] { "user", "logicalUserName" }, user)) { + continue; + } + } + if (session != null && !session.isEmpty()) { + if (!fieldMatches(record, new String[] { "sessionId" }, session)) { + continue; + } + } + if (search != null && !search.isEmpty() && !containsSearch(record, search)) { + continue; + } + result.add(record); + } + return result; + } + + + private static boolean statusMatches(Map record, String wanted) { + Object statusCode = record.get("statusCode"); + if (!(statusCode instanceof Number n)) { + return false; + } + int status = n.intValue(); + if (wanted.length() == 3 && wanted.charAt(2) == 'x' && Character.isDigit(wanted.charAt(0))) { + return status / 100 == wanted.charAt(0) - '0'; + } + if (wanted.length() == 3 && wanted.chars().allMatch(Character::isDigit)) { + return status == Integer.parseInt(wanted); + } + return false; + } + + + /** + * Case-insensitive "contains" match of the wanted text in one of the given fields. + */ + private static boolean fieldMatches(Map record, String[] keys, String wanted) { + String needle = wanted.toLowerCase(Locale.ROOT); + for (String key : keys) { + Object value = record.get(key); + if (value instanceof String s && s.toLowerCase(Locale.ROOT).contains(needle)) { + return true; + } + } + return false; + } + + + /** + * Case-insensitive "contains" match of the wanted text in the printable values of a record. + */ + private static boolean containsSearch(Map record, String wanted) { + String needle = wanted.toLowerCase(Locale.ROOT); + for (Map.Entry entry : record.entrySet()) { + Object value = entry.getValue(); + if (value instanceof String s) { + if (s.toLowerCase(Locale.ROOT).contains(needle)) { + return true; + } + } else if (value instanceof Number) { + if (String.valueOf(value).contains(needle)) { + return true; + } + } else if (value instanceof List list) { + for (Object item : list) { + if (item instanceof String s && s.toLowerCase(Locale.ROOT).contains(needle)) { + return true; + } + } + } + } + return false; + } + + + // ------------------------------------------------------------- Helpers + + + /** + * The logs directory of this Tomcat instance. + * + * @return the directory or {@code null} when it cannot be determined or does not exist + */ + private static File logsDirectory() { + String base = System.getProperty("catalina.base"); + if (base == null) { + return null; + } + File dir = new File(base, "logs"); + return dir.isDirectory() ? dir : null; + } + + + /** + * Read the last {@link #READ_CAP} bytes of a file, starting on a line boundary. + */ + private static byte[] readTail(File file) throws IOException { + long length = file.length(); + long offset = Math.max(0, length - READ_CAP); + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + raf.seek(offset); + byte[] buffer = new byte[(int) (length - offset)]; + if (buffer.length > 0) { + raf.readFully(buffer); + } + if (offset == 0) { + return buffer; + } + int i = 0; + while (i < buffer.length && buffer[i] != '\n') { + i++; + } + if (i >= buffer.length) { + return new byte[0]; + } + return Arrays.copyOfRange(buffer, i + 1, buffer.length); + } + } + + + private static long estimatedBytes(List lines) { + long bytes = 0; + for (String line : lines) { + bytes += line.length() + 1; + } + return bytes; + } + + + /** + * The {@code lines} most recent records, ordered from most recent to least recent (the records are built in file + * order, i.e. from oldest to most recent). + */ + private static List> tail(List> records, int lines) { + int from = Math.max(0, records.size() - lines); + List> result = new ArrayList<>(records.subList(from, records.size())); + Collections.reverse(result); + return result; + } + + + /** + * Detect the format of a log file from its first non-empty line: a line that starts with + * {@code {} is JSON, anything else is plain text. + */ + private static String detectFormat(File file) { + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + int toRead = (int) Math.min(FORMAT_CAP, file.length()); + byte[] buffer = new byte[toRead]; + if (toRead > 0) { + raf.readFully(buffer); + } + String content = new String(buffer, StandardCharsets.UTF_8); + for (String line : content.split("\\r?\\n", -1)) { + if (line.isEmpty()) { + continue; + } + return line.charAt(0) == '{' ? "json" : "text"; + } + } catch (IOException e) { + return "text"; + } + return "text"; + } + + + /** + * The access log pattern that applies to a file: the pattern of the configured access log valve (when present) or, + * for plain text files, one of the standard patterns that matches the first line. + */ + private String findPattern(File file, String format) { + String valvePattern = findValvePattern(); + if (valvePattern != null) { + return valvePattern; + } + if ("json".equals(format)) { + return null; + } + String firstLine = firstNonEmptyLine(file); + if (firstLine == null) { + return null; + } + for (String candidate : new String[] { org.apache.catalina.valves.Constants.AccessLog.COMMON_PATTERN, + org.apache.catalina.valves.Constants.AccessLog.COMBINED_PATTERN }) { + try { + if (LogParser.compile(candidate).parse(firstLine) != null) { + return candidate; + } + } catch (IllegalArgumentException e) { + // Try the next candidate. + } + } + return null; + } + + + /** + * The ordered list of fields a record of this file can have. + */ + private List findFields(File file, String format, String pattern) { + if ("json".equals(format)) { + String firstLine = firstNonEmptyLine(file); + if (firstLine == null) { + return null; + } + Map record = LogParser.parseJsonAccessLine(firstLine); + if (record == null) { + return null; + } + return new ArrayList<>(record.keySet()); + } + if (pattern != null) { + try { + return LogParser.compile(pattern).getFields(); + } catch (IllegalArgumentException e) { + return null; + } + } + return null; + } + + + private static String firstNonEmptyLine(File file) { + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + int toRead = (int) Math.min(FORMAT_CAP, file.length()); + byte[] buffer = new byte[toRead]; + if (toRead > 0) { + raf.readFully(buffer); + } + String content = new String(buffer, StandardCharsets.UTF_8); + for (String line : content.split("\\r?\\n", -1)) { + if (!line.isEmpty()) { + return line; + } + } + } catch (IOException e) { + return null; + } + return null; + } + + + /** + * The pattern of the access log valve installed on this host or its engine, or {@code null} when no access log + * valve is configured. + */ + private String findValvePattern() { + if (host == null) { + return null; + } + String pattern = firstValvePattern(host); + if (pattern != null) { + return pattern; + } + Container engine = host.getParent(); + return engine == null ? null : firstValvePattern(engine); + } + + + private static String firstValvePattern(Container container) { + for (Valve valve : container.getPipeline().getValves()) { + if (valve instanceof AbstractAccessLogValve accessLogValve) { + String pattern = accessLogValve.getPattern(); + if (pattern != null && !pattern.isEmpty()) { + return pattern; + } + } + } + return null; + } + + + private static String param(HttpServletRequest request, String name) { + String value = request.getParameter(name); + return (value == null || value.isEmpty()) ? null : value; + } + + + @Override + public String getServletInfo() { + return "Manager2 Logs API"; + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java new file mode 100644 index 000000000000..3d70b9f66918 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java @@ -0,0 +1,372 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import javax.management.MBeanServer; +import javax.management.MBeanServerNotification; +import javax.management.Notification; +import javax.management.NotificationListener; +import javax.management.ObjectInstance; +import javax.management.ObjectName; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.catalina.ContainerServlet; +import org.apache.catalina.Context; +import org.apache.catalina.Host; +import org.apache.catalina.Wrapper; +import org.apache.catalina.util.ServerInfo; +import org.apache.tomcat.util.modeler.Registry; +import org.apache.tomcat.util.res.StringManager; + + +/** + * The Manager2 status API. Serves the compact live snapshot, the live worker table and the detailed per-application + * state. The MBean queries mirror {@link org.apache.catalina.manager.StatusManagerServlet}. + */ +public class StatusApiServlet extends HttpServlet implements ContainerServlet, NotificationListener { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + + private transient MBeanServer mBeanServer = null; + + private final List threadPools = Collections.synchronizedList(new ArrayList<>()); + + private final List globalRequestProcessors = Collections.synchronizedList(new ArrayList<>()); + + private final List requestProcessors = Collections.synchronizedList(new ArrayList<>()); + + private transient Wrapper wrapper = null; + + private transient Context context = null; + + private transient Host host = null; + + private transient StatusHistory history = null; + + private transient ScheduledFuture historyTask = null; + + private transient ScheduledExecutorService ownExecutor = null; + + + // ------------------------------------------------ ContainerServlet API + + + @Override + public Wrapper getWrapper() { + return wrapper; + } + + + @Override + public void setWrapper(Wrapper wrapper) { + this.wrapper = wrapper; + if (wrapper == null) { + context = null; + host = null; + } else { + context = (Context) wrapper.getParent(); + host = (Host) context.getParent(); + } + } + + + // -------------------------------------------------------- Lifecycle API + + + @Override + public void init() throws ServletException { + + mBeanServer = Registry.getRegistry(null).getMBeanServer(); + + try { + threadPools.addAll(queryNames(mBeanServer, "*:type=ThreadPool,*")); + globalRequestProcessors.addAll(queryNames(mBeanServer, "*:type=GlobalRequestProcessor,*")); + requestProcessors.addAll(queryNames(mBeanServer, "*:type=RequestProcessor,*")); + + mBeanServer.addNotificationListener(new ObjectName("JMImplementation:type=MBeanServerDelegate"), this, null, + null); + } catch (Exception e) { + log(sm.getString("manager2.error.jmx"), e); + } + + // Start the background collection of status samples for + // /api/status/history. The collection period and the window of + // history are configurable through the "tickMs" and "windowMs" + // init parameters. + long tickMs = getLongInitParameter("tickMs", StatusHistory.DEFAULT_TICK_MS, 500); + long windowMs = getLongInitParameter("windowMs", StatusHistory.DEFAULT_WINDOW_MS, tickMs); + history = new StatusHistory(tickMs, windowMs); + // The server wide utility executor is exposed as a ServletContext + // attribute (see StandardContext.configStart). Only if it is not + // available (should not happen) a private executor is created and + // shut down again on destroy. + ScheduledExecutorService executor = (ScheduledExecutorService) getServletConfig().getServletContext() + .getAttribute(org.apache.tomcat.util.threads.ScheduledThreadPoolExecutor.class.getName()); + if (executor == null) { + ownExecutor = new ScheduledThreadPoolExecutor(1); + executor = ownExecutor; + } + historyTask = executor.scheduleAtFixedRate(this::recordHistory, 0, tickMs, TimeUnit.MILLISECONDS); + } + + + @Override + public void destroy() { + if (historyTask != null) { + historyTask.cancel(false); + historyTask = null; + } + if (ownExecutor != null) { + ownExecutor.shutdown(); + ownExecutor = null; + } + try { + mBeanServer.removeNotificationListener(new ObjectName("JMImplementation:type=MBeanServerDelegate"), this, + null, null); + } catch (Exception e) { + log(sm.getString("manager2.error.jmx"), e); + } + } + + + // ------------------------------------------------------------ Request API + + + @Override + public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + + String path = request.getServletPath(); + String info = request.getPathInfo(); + if (info != null && !info.isEmpty()) { + path = path + info; + } + + try { + if (path.startsWith("/api/info")) { + Api.json(response, info()); + } else if (path.startsWith("/api/csrf")) { + String token = (String) request.getSession(false).getAttribute(Constants.CSRF_TOKEN_SESSION_KEY); + Map payload = new LinkedHashMap<>(); + payload.put("token", token); + Api.json(response, payload); + } else if (path.startsWith("/api/status/apps/")) { + // The context path is taken from the "path" query parameter + // when present (consistent with the rest of the API, where + // an empty value means the ROOT context), otherwise from + // the path segment. + String contextParam = request.getParameter("path"); + String hostParam = request.getParameter("host"); + String contextPath; + String hostName; + if (contextParam != null) { + contextPath = contextParam; + hostName = hostParam != null ? hostParam : host.getName(); + } else { + String rest = path.substring("/api/status/apps/".length()); + int slash = rest.lastIndexOf('/'); + String segment = slash >= 0 ? rest.substring(0, slash) : rest; + hostName = slash >= 0 ? rest.substring(slash + 1) : host.getName(); + contextPath = segment.equals("root") ? "" : segment; + if (!contextPath.isEmpty() && !contextPath.startsWith("/")) { + contextPath = "/" + contextPath; + } + } + if (!hostName.equals(host.getName())) { + Api.notFound(response); + return; + } + Map detail = StatusSnapshot.application(mBeanServer, hostName, contextPath); + if (detail == null) { + Api.notFound(response); + } else { + Api.json(response, detail); + } + } else if (path.startsWith("/api/status/workers")) { + Api.json(response, StatusSnapshot.workers(mBeanServer, requestProcessors)); + } else if (path.startsWith("/api/status/history")) { + if (history == null) { + Api.error(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "NOT_AVAILABLE", + "The status history is not available."); + } else { + Api.json(response, history.payload()); + } + } else if (path.startsWith("/api/status")) { + Api.json(response, StatusSnapshot.snapshot(mBeanServer, threadPools, host)); + } else { + Api.notFound(response); + } + } catch (Exception e) { + log(sm.getString("manager2.error.status"), e); + throw new ServletException(e); + } + } + + + private Map info() { + + Map result = new LinkedHashMap<>(); + + Map server = new LinkedHashMap<>(); + server.put("info", ServerInfo.getServerInfo()); + server.put("javaRuntimeVersion", System.getProperty("java.runtime.version")); + server.put("javaVmVendor", System.getProperty("java.vm.vendor")); + result.put("server", server); + + Map os = new LinkedHashMap<>(); + os.put("name", System.getProperty("os.name")); + os.put("version", System.getProperty("os.version")); + os.put("arch", System.getProperty("os.arch")); + result.put("os", os); + + Map runtime = new LinkedHashMap<>(); + long startTime = java.lang.management.ManagementFactory.getRuntimeMXBean().getStartTime(); + runtime.put("startTime", Long.valueOf(startTime)); + runtime.put("uptimeMs", Long.valueOf(System.currentTimeMillis() - startTime)); + runtime.put("availableProcessors", Integer.valueOf(Runtime.getRuntime().availableProcessors())); + result.put("runtime", runtime); + + Map hostInfo = new LinkedHashMap<>(); + hostInfo.put("name", host != null ? host.getName() : null); + try { + java.net.InetAddress address = java.net.InetAddress.getLocalHost(); + hostInfo.put("hostName", address.getHostName()); + hostInfo.put("ipAddress", address.getHostAddress()); + } catch (java.net.UnknownHostException e) { + hostInfo.put("hostName", "-"); + hostInfo.put("ipAddress", "-"); + } + result.put("host", hostInfo); + + return result; + } + + + /** + * One tick of the background collection: take a snapshot of the current runtime state and add it to the history. A + * failure must never escape: with {@code scheduleAtFixedRate} an exception in one execution would suppress all + * following ones. + */ + private void recordHistory() { + try { + history.record(StatusSnapshot.snapshot(mBeanServer, threadPools, host)); + } catch (Exception e) { + log(sm.getString("manager2.error.status"), e); + } + } + + + /** + * Read a positive {@code long} init parameter, falling back to the given default (with a warning) when the + * parameter is missing, not a number or below the given minimum. + * + * @param name the init parameter name + * @param defaultValue the value to use when the parameter is absent or invalid + * @param minimum the smallest acceptable value (inclusive) + * + * @return the effective value + */ + private long getLongInitParameter(String name, long defaultValue, long minimum) { + String value = getServletConfig().getInitParameter(name); + if (value != null && !value.isEmpty()) { + try { + long parsed = Long.parseLong(value.trim()); + if (parsed >= minimum) { + return parsed; + } + } catch (NumberFormatException e) { + // Fall through to the default + } + log(sm.getString("manager2.history.invalidParameter", name, value)); + } + return defaultValue; + } + + + // -------------------------------------------- NotificationListener API + + + @Override + public void handleNotification(Notification notification, Object handback) { + + if (notification instanceof MBeanServerNotification) { + ObjectName objectName = ((MBeanServerNotification) notification).getMBeanName(); + String type = objectName.getKeyProperty("type"); + if (type == null) { + return; + } + if (MBeanServerNotification.REGISTRATION_NOTIFICATION.equals(notification.getType())) { + switch (type) { + case "ThreadPool" -> threadPools.add(objectName); + case "GlobalRequestProcessor" -> globalRequestProcessors.add(objectName); + case "RequestProcessor" -> requestProcessors.add(objectName); + default -> { + } + } + } else if (MBeanServerNotification.UNREGISTRATION_NOTIFICATION.equals(notification.getType())) { + switch (type) { + case "ThreadPool" -> threadPools.remove(objectName); + case "GlobalRequestProcessor" -> globalRequestProcessors.remove(objectName); + case "RequestProcessor" -> requestProcessors.remove(objectName); + default -> { + } + } + } + } + } + + + private static List queryNames(MBeanServer mBeanServer, String query) throws Exception { + List result = new ArrayList<>(); + Set instances = mBeanServer.queryMBeans(new ObjectName(query), null); + for (ObjectInstance instance : instances) { + result.add(instance.getObjectName()); + } + return result; + } + + + @Override + public String getServletInfo() { + return "Manager2 Status API"; + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusHistory.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusHistory.java new file mode 100644 index 000000000000..04bcd49e9fbb --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusHistory.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +/** + * A rolling, in-memory history of status samples, collected at a fixed tick interval by a background task and served by + * {@code GET /api/status/history}. The history holds the samples of the last {@code windowMs} milliseconds (the default + * of 10 minutes at a 2 second tick is 300 samples) and drives the charts of the Dashboard, which therefore always show + * the last {@code windowMs} of server activity regardless of when the page was opened. + *

+ * Each sample aggregates the values the Dashboard displays: JVM heap usage, busy threads and cumulative + * request/error/byte counters summed over all connectors, plus the active session count of all deployed applications. + * The per-second rates (requests/s, errors/s, bytes/s) are derived from the difference to the previous sample at + * collection time, so consumers do not need to keep their own baseline. The rates of the very first sample are + * {@code null} because there is no baseline yet. + *

+ * Samples are immutable once recorded. The recording thread is the scheduled collection task; readers are the API + * servlet threads. Synchronizing the two operations is sufficient for safe publication. + */ +public final class StatusHistory { + + + /** + * The default collection period in milliseconds (2 seconds). + */ + public static final long DEFAULT_TICK_MS = 2000; + + + /** + * The default collection window in milliseconds (10 minutes). + */ + public static final long DEFAULT_WINDOW_MS = 10 * 60 * 1000; + + + private final long tickMs; + + private final long windowMs; + + private final Deque samples = new ArrayDeque<>(); + + private List> latestApps = List.of(); + + private long previousTs = -1; + + private long previousCount; + + private long previousErrors; + + private long previousBytesSent; + + private long previousBytesReceived; + + + /** + * Create a new history. + * + * @param tickMs the collection period in milliseconds + * @param windowMs the window of history to keep in milliseconds (must be at least one tick) + */ + public StatusHistory(long tickMs, long windowMs) { + this.tickMs = tickMs; + this.windowMs = windowMs; + } + + + /** + * Record a new sample from the given snapshot (the result of {@link StatusSnapshot#snapshot}). This method is + * called by the scheduled collection task and must not be called concurrently with itself. + * + * @param snap the snapshot of the current runtime state + */ + public synchronized void record(Map snap) { + + long ts = asLong(snap.get("ts")); + + long heapUsed = 0; + long heapCommitted = 0; + long heapMax = 0; + if (snap.get("jvm") instanceof Map jvm && jvm.get("memory") instanceof Map memory) { + heapUsed = asLong(memory.get("used")); + heapCommitted = asLong(memory.get("committed")); + heapMax = asLong(memory.get("max")); + } + + long threadsBusy = 0; + long threadsMax = 0; + long count = 0; + long errors = 0; + long bytesSent = 0; + long bytesReceived = 0; + if (snap.get("connectors") instanceof List connectors) { + for (Object connectorObj : connectors) { + Map connector = asMap(connectorObj); + if (connector == null) { + continue; + } + if (connector.get("threads") instanceof Map threads) { + threadsMax += asLong(threads.get("max")); + threadsBusy += asLong(threads.get("busy")); + } + if (connector.get("requests") instanceof Map requests) { + count += asLong(requests.get("count")); + errors += asLong(requests.get("errors")); + bytesSent += asLong(requests.get("bytesSent")); + bytesReceived += asLong(requests.get("bytesReceived")); + } + } + } + + long sessions = 0; + List> apps = new ArrayList<>(); + if (snap.get("apps") instanceof List appList) { + for (Object app : appList) { + Map appMap = asMap(app); + if (appMap != null) { + sessions += asLong(appMap.get("activeSessions")); + apps.add(appMap); + } + } + } + + Double rps = null; + Double eps = null; + Double bpsSent = null; + Double bpsRecv = null; + if (previousTs > 0) { + double dt = (ts - previousTs) / 1000.0; + if (dt > 0 && dt < 30) { + rps = Math.max(0, (count - previousCount) / dt); + eps = Math.max(0, (errors - previousErrors) / dt); + bpsSent = Math.max(0, (bytesSent - previousBytesSent) / dt); + bpsRecv = Math.max(0, (bytesReceived - previousBytesReceived) / dt); + } + } + + Map json = new LinkedHashMap<>(); + json.put("ts", Long.valueOf(ts)); + json.put("heapUsed", Long.valueOf(heapUsed)); + json.put("heapCommitted", Long.valueOf(heapCommitted)); + json.put("heapMax", Long.valueOf(heapMax)); + json.put("threadsBusy", Long.valueOf(threadsBusy)); + json.put("threadsMax", Long.valueOf(threadsMax)); + json.put("sessions", Long.valueOf(sessions)); + json.put("rps", rps); + json.put("eps", eps); + json.put("bpsSent", bpsSent); + json.put("bpsRecv", bpsRecv); + + samples.addLast(new Sample(ts, json)); + long cutoff = ts - windowMs; + while (!samples.isEmpty() && samples.peekFirst().ts < cutoff) { + samples.removeFirst(); + } + + previousTs = ts; + previousCount = count; + previousErrors = errors; + previousBytesSent = bytesSent; + previousBytesReceived = bytesReceived; + latestApps = apps; + } + + + /** + * Build the JSON payload served by {@code GET /api/status/history}: the configuration ({@code windowMs}, + * {@code tickMs}), the samples of the current window (oldest first) and the application list of the latest sample. + * + * @return the payload + */ + public synchronized Map payload() { + Map result = new LinkedHashMap<>(); + result.put("windowMs", Long.valueOf(windowMs)); + result.put("tickMs", Long.valueOf(tickMs)); + List> json = new ArrayList<>(samples.size()); + for (Sample sample : samples) { + json.add(sample.json); + } + result.put("samples", json); + result.put("apps", latestApps); + return result; + } + + + /** + * A single recorded sample. + */ + private static final class Sample { + + private final long ts; + + private final Map json; + + private Sample(long ts, Map json) { + this.ts = ts; + this.json = json; + } + } + + + private static long asLong(Object value) { + if (value instanceof Number n) { + return n.longValue(); + } + return 0; + } + + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return null; + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java new file mode 100644 index 000000000000..7a417985ad59 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java @@ -0,0 +1,354 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryUsage; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import javax.management.MBeanServer; +import javax.management.ObjectName; + +import org.apache.catalina.Context; +import org.apache.catalina.Container; +import org.apache.catalina.Host; +import org.apache.catalina.Manager; + + +/** + * Collects runtime statistics from the platform and JMX MBeans and turns them into {@code Map}/{@code List} structures + * that are JSON-serialized by {@link Json}. The MBean queries mirror the ones used by + * {@link org.apache.catalina.manager.StatusTransformer}. + */ +public final class StatusSnapshot { + + + /** + * Build the compact live snapshot served by {@code GET /api/status}. + * + * @param mBeanServer the MBean server + * @param threadPools ObjectNames of the connector thread pools + * @param host the Host the API is installed in (may be null, in which case the application summary is empty) + * + * @return the snapshot + */ + public static Map snapshot(MBeanServer mBeanServer, List threadPools, Host host) + throws Exception { + Map result = new LinkedHashMap<>(); + result.put("ts", Long.valueOf(System.currentTimeMillis())); + result.put("jvm", jvm()); + result.put("connectors", connectors(mBeanServer, threadPools)); + result.put("apps", apps(host)); + return result; + } + + + /** + * Build the live per-socket (RequestProcessor) table served by {@code GET /api/status/workers}. + * + * @param mBeanServer the MBean server + * @param requestProcessors ObjectNames of the request processors + * + * @return the worker table + */ + public static List> workers(MBeanServer mBeanServer, List requestProcessors) + throws Exception { + List> result = new ArrayList<>(); + for (ObjectName oname : requestProcessors) { + Map worker = new LinkedHashMap<>(); + Integer stageValue; + try { + stageValue = (Integer) mBeanServer.getAttribute(oname, "stage"); + } catch (Exception e) { + // Processor went away while we were reading it + continue; + } + int stage = stageValue.intValue(); + String stageStr; + boolean fullStatus = true; + boolean showRequest = true; + switch (stage) { + case org.apache.coyote.Constants.STAGE_PARSE: + case org.apache.coyote.Constants.STAGE_PREPARE: + stageStr = "P"; + fullStatus = false; + break; + case org.apache.coyote.Constants.STAGE_SERVICE: + stageStr = "S"; + break; + case org.apache.coyote.Constants.STAGE_ENDINPUT: + case org.apache.coyote.Constants.STAGE_ENDOUTPUT: + stageStr = "F"; + break; + case org.apache.coyote.Constants.STAGE_ENDED: + case org.apache.coyote.Constants.STAGE_NEW: + stageStr = "R"; + fullStatus = false; + break; + case org.apache.coyote.Constants.STAGE_KEEPALIVE: + stageStr = "K"; + showRequest = false; + break; + default: + stageStr = "?"; + fullStatus = false; + } + worker.put("stage", stageStr); + if (fullStatus) { + worker.put("time", number(mBeanServer.getAttribute(oname, "requestProcessingTime"))); + worker.put("bytesSent", + showRequest ? number(mBeanServer.getAttribute(oname, "requestBytesSent")) : null); + worker.put("bytesReceived", + showRequest ? number(mBeanServer.getAttribute(oname, "requestBytesReceived")) : null); + worker.put("remoteAddrForwarded", string(mBeanServer.getAttribute(oname, "remoteAddrForwarded"))); + worker.put("remoteAddr", string(mBeanServer.getAttribute(oname, "remoteAddr"))); + worker.put("virtualHost", string(mBeanServer.getAttribute(oname, "virtualHost"))); + if (showRequest) { + worker.put("method", string(mBeanServer.getAttribute(oname, "method"))); + worker.put("uri", string(mBeanServer.getAttribute(oname, "currentUri"))); + worker.put("queryString", string(mBeanServer.getAttribute(oname, "currentQueryString"))); + worker.put("protocol", string(mBeanServer.getAttribute(oname, "protocol"))); + } + } + result.add(worker); + } + return result; + } + + + /** + * Build the detailed per-application state served by {@code GET /api/status/apps/{path}}. + * + * @param mBeanServer the MBean server + * @param hostName the name of the host the context is installed in + * @param contextPath the context path (empty string for ROOT) + * + * @return the detailed state, or {@code null} if the context is not (any longer) deployed + */ + public static Map application(MBeanServer mBeanServer, String hostName, String contextPath) + throws Exception { + // The WebModule MBean is keyed by "name" = "//" + host + context + // (see StandardContext.getObjectNameKeyProperties()). + String webModuleKey = "//" + hostName + + (contextPath.startsWith("/") ? contextPath : (contextPath.isEmpty() ? "/" : "/" + contextPath)); + ObjectName contextOn = findWebModule(mBeanServer, webModuleKey); + if (contextOn == null) { + return null; + } + + Map result = new LinkedHashMap<>(); + result.put("name", hostName + contextPath); + result.put("state", string(mBeanServer.getAttribute(contextOn, "stateName"))); + Long startTime = (Long) mBeanServer.getAttribute(contextOn, "startTime"); + result.put("startTime", startTime); + result.put("startupTime", number(mBeanServer.getAttribute(contextOn, "startupTime"))); + result.put("tldScanTime", number(mBeanServer.getAttribute(contextOn, "tldScanTime"))); + + ObjectName managerOn = findUnique(mBeanServer, + contextOn.getDomain() + ":type=Manager,context=" + contextPath + ",host=" + hostName + ",*"); + if (managerOn != null) { + Map manager = new LinkedHashMap<>(); + manager.put("activeSessions", number(mBeanServer.getAttribute(managerOn, "activeSessions"))); + manager.put("sessionCounter", number(mBeanServer.getAttribute(managerOn, "sessionCounter"))); + manager.put("maxActive", number(mBeanServer.getAttribute(managerOn, "maxActive"))); + manager.put("rejectedSessions", number(mBeanServer.getAttribute(managerOn, "rejectedSessions"))); + manager.put("expiredSessions", number(mBeanServer.getAttribute(managerOn, "expiredSessions"))); + manager.put("sessionMaxAliveTime", number(mBeanServer.getAttribute(managerOn, "sessionMaxAliveTime"))); + manager.put("sessionAverageAliveTime", + number(mBeanServer.getAttribute(managerOn, "sessionAverageAliveTime"))); + manager.put("processingTime", number(mBeanServer.getAttribute(managerOn, "processingTime"))); + result.put("manager", manager); + } + + Set jspMonitorOns = mBeanServer.queryNames( + new ObjectName(contextOn.getDomain() + ":type=JspMonitor,WebModule=" + webModuleKey + ",*"), null); + long jspCount = 0; + long jspReloadCount = 0; + for (ObjectName jspMonitorOn : jspMonitorOns) { + jspCount += ((Integer) mBeanServer.getAttribute(jspMonitorOn, "jspCount")).intValue(); + jspReloadCount += ((Integer) mBeanServer.getAttribute(jspMonitorOn, "jspReloadCount")).intValue(); + } + if (jspMonitorOns.size() > 0) { + Map jsp = new LinkedHashMap<>(); + jsp.put("jspCount", Long.valueOf(jspCount)); + jsp.put("jspReloadCount", Long.valueOf(jspReloadCount)); + result.put("jsp", jsp); + } + + String servletQuery = contextOn.getDomain() + ":j2eeType=Servlet,WebModule=" + webModuleKey + ",*"; + Set servlets = mBeanServer.queryMBeans(new ObjectName(servletQuery), null); + List> wrappers = new ArrayList<>(); + for (javax.management.ObjectInstance oi : servlets) { + ObjectName wrapperOn = oi.getObjectName(); + Map wrapper = new LinkedHashMap<>(); + wrapper.put("name", oi.getObjectName().getKeyProperty("name")); + String[] mappings = (String[]) mBeanServer.invoke(wrapperOn, "findMappings", null, null); + if (mappings != null && mappings.length > 0) { + wrapper.put("mappings", List.of(mappings)); + } + wrapper.put("processingTime", number(mBeanServer.getAttribute(wrapperOn, "processingTime"))); + wrapper.put("maxTime", number(mBeanServer.getAttribute(wrapperOn, "maxTime"))); + wrapper.put("requestCount", number(mBeanServer.getAttribute(wrapperOn, "requestCount"))); + wrapper.put("errorCount", number(mBeanServer.getAttribute(wrapperOn, "errorCount"))); + wrapper.put("loadTime", number(mBeanServer.getAttribute(wrapperOn, "loadTime"))); + wrapper.put("classLoadTime", number(mBeanServer.getAttribute(wrapperOn, "classLoadTime"))); + wrappers.add(wrapper); + } + wrappers.sort((a, b) -> String.valueOf(a.get("name")).compareTo(String.valueOf(b.get("name")))); + result.put("wrappers", wrappers); + + return result; + } + + + private static Map jvm() { + Map result = new LinkedHashMap<>(); + + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + Map memory = new LinkedHashMap<>(); + memory.put("used", Long.valueOf(heap.getUsed())); + memory.put("committed", Long.valueOf(heap.getCommitted())); + memory.put("max", Long.valueOf(heap.getMax())); + result.put("memory", memory); + result.put("nonHeapUsed", Long.valueOf(ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getUsed())); + + Map pools = new TreeMap<>(); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + pools.put(pool.getType().toString() + ":" + pool.getName(), pool); + } + List> poolList = new ArrayList<>(); + for (MemoryPoolMXBean pool : pools.values()) { + MemoryUsage usage = pool.getUsage(); + Map entry = new LinkedHashMap<>(); + entry.put("name", pool.getName()); + entry.put("type", pool.getType().toString()); + entry.put("init", Long.valueOf(usage.getInit())); + entry.put("committed", Long.valueOf(usage.getCommitted())); + entry.put("max", Long.valueOf(usage.getMax())); + entry.put("used", Long.valueOf(usage.getUsed())); + poolList.add(entry); + } + result.put("pools", poolList); + return result; + } + + + private static List> connectors(MBeanServer mBeanServer, List threadPools) + throws Exception { + List> result = new ArrayList<>(); + for (ObjectName pool : threadPools) { + String name = pool.getKeyProperty("name"); + + Map connector = new LinkedHashMap<>(); + connector.put("name", name); + + Map threads = new LinkedHashMap<>(); + threads.put("max", number(mBeanServer.getAttribute(pool, "maxThreads"))); + threads.put("current", number(mBeanServer.getAttribute(pool, "currentThreadCount"))); + threads.put("busy", number(mBeanServer.getAttribute(pool, "currentThreadsBusy"))); + threads.put("keepAlive", number(mBeanServer.getAttribute(pool, "keepAliveCount"))); + connector.put("threads", threads); + + ObjectName group = findRequestGroup(mBeanServer, name); + if (group != null) { + Map requests = new LinkedHashMap<>(); + requests.put("maxTime", number(mBeanServer.getAttribute(group, "maxTime"))); + requests.put("processingTime", number(mBeanServer.getAttribute(group, "processingTime"))); + requests.put("count", number(mBeanServer.getAttribute(group, "requestCount"))); + requests.put("errors", number(mBeanServer.getAttribute(group, "errorCount"))); + requests.put("bytesReceived", number(mBeanServer.getAttribute(group, "bytesReceived"))); + requests.put("bytesSent", number(mBeanServer.getAttribute(group, "bytesSent"))); + connector.put("requests", requests); + } + result.add(connector); + } + return result; + } + + + private static List> apps(Host host) { + List> result = new ArrayList<>(); + if (host == null) { + return result; + } + Container[] children = host.findChildren(); + for (Container child : children) { + Context context = (Context) child; + Map app = new LinkedHashMap<>(); + app.put("path", context.getPath()); + app.put("host", host.getName()); + app.put("state", context.getState().toString()); + Manager manager = context.getManager(); + app.put("activeSessions", manager != null ? Long.valueOf(manager.getActiveSessions()) : 0L); + result.add(app); + } + return result; + } + + + private static ObjectName findWebModule(MBeanServer mBeanServer, String webModuleKey) throws Exception { + Set names = mBeanServer + .queryNames(new ObjectName("*:j2eeType=WebModule,name=" + webModuleKey + ",*"), null); + for (ObjectName name : names) { + return name; + } + return null; + } + + + private static ObjectName findUnique(MBeanServer mBeanServer, String query) throws Exception { + ObjectName result = null; + for (ObjectName name : mBeanServer.queryNames(new ObjectName(query), null)) { + result = name; + } + return result; + } + + + private static ObjectName findRequestGroup(MBeanServer mBeanServer, String connectorName) throws Exception { + for (ObjectName name : mBeanServer.queryNames(new ObjectName("*:type=GlobalRequestProcessor,*"), null)) { + if (connectorName.equals(name.getKeyProperty("name")) && name.getKeyProperty("Upgrade") == null) { + return name; + } + } + return null; + } + + + private static Long number(Object value) { + if (value instanceof Number n) { + return Long.valueOf(n.longValue()); + } + return 0L; + } + + + private static String string(Object value) { + return value == null ? null : value.toString(); + } + + + private StatusSnapshot() { + // Utility class, do not instantiate + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java new file mode 100644 index 000000000000..532b10b184cd --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import org.apache.tomcat.util.res.StringManager; + + +/** + * Helper for the creation of the {@link StringManager} of this web application. + *

+ * The {@link StringManager} cache is JVM wide and the first creation for a package wins. The class loaders of the first + * thread that initializes one of the classes of this web application matter: the {@code LocalStrings} bundle is looked + * up with the class loader of {@code StringManager} (the common class loader, which does not see the web application) + * and, on failure, with the context class loader of that thread. A class may be initialized by a thread that is not a + * request thread (for example the initialization of the filters of this web application during context start), in which + * case the context class loader does not see the web application either and the {@link StringManager} would be created + * without a bundle. To make the first creation deterministic, the context class loader is temporarily set to the class + * loader of this web application. + */ +final class Strings { + + + /** + * The {@link StringManager} for the package of this web application, created in a way that always finds the + * {@code LocalStrings} bundle of this web application. + * + * @return the {@link StringManager} for this web application + */ + static StringManager manager() { + Thread thread = Thread.currentThread(); + ClassLoader own = Strings.class.getClassLoader(); + ClassLoader previous = thread.getContextClassLoader(); + if (previous == own) { + return StringManager.getManager(Constants.Package); + } + thread.setContextClassLoader(own); + try { + return StringManager.getManager(Constants.Package); + } finally { + thread.setContextClassLoader(previous); + } + } + + + private Strings() { + // Utility class, do not instantiate + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java new file mode 100644 index 000000000000..43fd3bfbf4d7 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java @@ -0,0 +1,918 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.io.Serial; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import javax.naming.Binding; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.Reference; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.catalina.Container; +import org.apache.catalina.ContainerServlet; +import org.apache.catalina.Context; +import org.apache.catalina.Engine; +import org.apache.catalina.Group; +import org.apache.catalina.Role; +import org.apache.catalina.Server; +import org.apache.catalina.Service; +import org.apache.catalina.User; +import org.apache.catalina.UserDatabase; +import org.apache.catalina.Wrapper; +import org.apache.catalina.users.MemoryUserDatabase; +import org.apache.tomcat.util.json.JSONParser; +import org.apache.tomcat.util.res.StringManager; + + +/** + * The Manager2 users API. Manages the users, groups and roles of the {@link UserDatabase} JNDI resources configured for + * this server (the default {@code server.xml} configures the file based {@code MemoryUserDatabase} that backs + * {@code conf/tomcat-users.xml}). + *

+ * User databases are discovered through the server's global JNDI naming context: every binding whose type is + * {@code org.apache.catalina.UserDatabase} is resolved and listed (other bindings are left untouched). When several + * databases are configured, the {@code name} JSON body field or query parameter selects the one to operate on. + *

+ * Passwords are stored exactly as provided, with the same semantics as the {@code password} attribute of + * {@code tomcat-users.xml}: the configured realm is responsible for comparing them during authentication. Passwords are + * never returned by this API. + *

+ * Like the rest of the API that is not part of the read-only status endpoints, this API requires the + * {@code manager-gui} role. + */ +public class UsersApiServlet extends HttpServlet implements ContainerServlet { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * The string manager for this package. + */ + protected static final StringManager sm = Strings.manager(); + + + /** + * The JNDI type of the user database resources. + */ + private static final String UDB_TYPE = UserDatabase.class.getName(); + + + private transient Wrapper wrapper = null; + + private transient Server server = null; + + + // ------------------------------------------------ ContainerServlet API + + + @Override + public Wrapper getWrapper() { + return wrapper; + } + + + @Override + public void setWrapper(Wrapper wrapper) { + this.wrapper = wrapper; + if (wrapper == null) { + server = null; + } else { + server = null; + Context context = (Context) wrapper.getParent(); + Container host = context.getParent(); + if (host != null) { + Container engine = host.getParent(); + if (engine instanceof Engine engineContainer) { + Service service = engineContainer.getService(); + if (service != null) { + server = service.getServer(); + } + } + } + } + } + + + // ------------------------------------------------------------ Request API + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = fullPath(request); + + try { + if ("/api/users".equals(path)) { + list(response, request.getParameter("name")); + } else { + Api.notFound(response); + } + } catch (Exception e) { + log(sm.getString("manager2.error.users"), e); + throw new ServletException(e); + } + } + + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = fullPath(request); + String[] tail = tail(path); + + try { + if ("/api/users".equals(path)) { + createUser(request, response); + } else if (path.startsWith("/api/users/") && tail.length == 2) { + if ("password".equals(tail[1])) { + setPassword(request, response, tail[0]); + } else if ("roles".equals(tail[1])) { + setUserRoles(request, response, tail[0]); + } else { + Api.notFound(response); + } + } else if ("/api/groups".equals(path)) { + createGroup(request, response); + } else if (path.startsWith("/api/groups/") && tail.length == 2) { + if ("members".equals(tail[1])) { + setGroupMembers(request, response, tail[0]); + } else if ("roles".equals(tail[1])) { + setGroupRoles(request, response, tail[0]); + } else { + Api.notFound(response); + } + } else if ("/api/roles".equals(path)) { + createRole(request, response); + } else { + Api.notFound(response); + } + } catch (Exception e) { + log(sm.getString("manager2.error.users"), e); + throw new ServletException(e); + } + } + + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String path = fullPath(request); + String[] tail = tail(path); + + try { + if (path.startsWith("/api/users/") && tail.length == 1) { + removeUser(request, response, tail[0]); + } else if (path.startsWith("/api/groups/") && tail.length == 1) { + removeGroup(request, response, tail[0]); + } else if (path.startsWith("/api/roles/") && tail.length == 1) { + removeRole(request, response, tail[0]); + } else { + Api.notFound(response); + } + } catch (Exception e) { + log(sm.getString("manager2.error.users"), e); + throw new ServletException(e); + } + } + + + // ---------------------------------------------------------------- List + + + private void list(HttpServletResponse response, String selected) throws IOException { + + List> found = discover(); + if (found.isEmpty()) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_DATABASE_MISSING", + sm.getString("manager2.userDatabaseMissing")); + return; + } + + Map.Entry dbEntry = select(selected, found, response); + if (dbEntry == null) { + return; + } + UserDatabase db = dbEntry.getValue(); + + List> databases = new ArrayList<>(); + for (Map.Entry entry : found) { + Map item = new LinkedHashMap<>(); + item.put("name", entry.getKey()); + item.put("id", entry.getValue().getId()); + item.put("type", entry.getValue().getClass().getSimpleName()); + item.put("readonly", readonly(entry.getValue())); + item.put("writable", writable(entry.getValue())); + databases.add(item); + } + + Map payload = new LinkedHashMap<>(); + payload.put("databases", databases); + payload.put("name", dbEntry.getKey()); + payload.put("readonly", readonly(db)); + payload.put("writable", writable(db)); + payload.put("users", listUsers(db)); + payload.put("groups", listGroups(db)); + payload.put("roles", listRoles(db)); + Api.json(response, payload); + } + + + private List> listUsers(UserDatabase db) { + List> result = new ArrayList<>(); + for (User user : collect(db.getUsers())) { + Map item = new LinkedHashMap<>(); + item.put("username", user.getUsername()); + item.put("fullName", user.getFullName()); + item.put("hasPassword", user.getPassword() != null); + List roles = collectNames(user.getRoles(), Role::getRolename); + List groups = collectNames(user.getGroups(), Group::getGroupname); + item.put("roles", roles); + item.put("groups", groups); + Set effective = new LinkedHashSet<>(roles); + for (String groupname : groups) { + Group group = db.findGroup(groupname); + if (group != null) { + effective.addAll(collectNames(group.getRoles(), Role::getRolename)); + } + } + item.put("effectiveRoles", new ArrayList<>(effective)); + result.add(item); + } + result.sort((a, b) -> String.valueOf(a.get("username")).compareTo(String.valueOf(b.get("username")))); + return result; + } + + + private List> listGroups(UserDatabase db) { + List> result = new ArrayList<>(); + for (Group group : collect(db.getGroups())) { + Map item = new LinkedHashMap<>(); + item.put("groupname", group.getGroupname()); + item.put("description", group.getDescription()); + item.put("roles", collectNames(group.getRoles(), Role::getRolename)); + // Drop members that no longer exist in the database. + List members = new ArrayList<>(); + for (User user : collect(group.getUsers())) { + if (db.findUser(user.getUsername()) != null) { + members.add(user.getUsername()); + } + } + members.sort(String::compareTo); + item.put("members", members); + result.add(item); + } + result.sort((a, b) -> String.valueOf(a.get("groupname")).compareTo(String.valueOf(b.get("groupname")))); + return result; + } + + + private List> listRoles(UserDatabase db) { + List> result = new ArrayList<>(); + for (Role role : collect(db.getRoles())) { + Map item = new LinkedHashMap<>(); + item.put("rolename", role.getRolename()); + item.put("description", role.getDescription()); + result.add(item); + } + result.sort((a, b) -> String.valueOf(a.get("rolename")).compareTo(String.valueOf(b.get("rolename")))); + return result; + } + + + // ------------------------------------------------------------- Mutations + + + private void createUser(HttpServletRequest request, HttpServletResponse response) throws IOException { + + Map body = readJson(request); + String username = asString(body.get("username")); + if (!validName(username)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.usernameMissing", username)); + return; + } + if (!body.containsKey("password") || !(body.get("password") instanceof String password)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", + sm.getString("manager2.passwordMissing")); + return; + } + List roles = asStringList(body.get("roles")); + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + if (!writableForMutation(db, response)) { + return; + } + + User user = db.createUser(username, password, asString(body.get("fullName"))); + if (user == null) { + Api.error(response, HttpServletResponse.SC_CONFLICT, "USER_EXISTS", + sm.getString("manager2.userExists", username)); + return; + } + for (String role : roles) { + user.addRole(findOrCreateRole(db, role)); + } + save(db, response); + } + + + private void removeUser(HttpServletRequest request, HttpServletResponse response, String username) + throws IOException { + + UserDatabase db = selected(request, null, response); + if (db == null) { + return; + } + User user = db.findUser(username); + if (user == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", + sm.getString("manager2.userNotFound", username)); + return; + } + if (request.getUserPrincipal() != null && username.equals(request.getUserPrincipal().getName())) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "SELF_REMOVAL", + sm.getString("manager2.selfRemoval")); + return; + } + if (!writableForMutation(db, response)) { + return; + } + db.removeUser(user); + save(db, response); + } + + + private void setPassword(HttpServletRequest request, HttpServletResponse response, String username) + throws IOException { + + Map body = readJson(request); + if (!body.containsKey("password") || !(body.get("password") instanceof String password)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", + sm.getString("manager2.passwordMissing")); + return; + } + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + User user = db.findUser(username); + if (user == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", + sm.getString("manager2.userNotFound", username)); + return; + } + if (!writableForMutation(db, response)) { + return; + } + user.setPassword(password); + save(db, response); + } + + + private void setUserRoles(HttpServletRequest request, HttpServletResponse response, String username) + throws IOException { + + Map body = readJson(request); + List roles = asStringList(body.get("roles")); + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + User user = db.findUser(username); + if (user == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", + sm.getString("manager2.userNotFound", username)); + return; + } + if (!writableForMutation(db, response)) { + return; + } + user.removeRoles(); + for (String role : roles) { + user.addRole(findOrCreateRole(db, role)); + } + save(db, response); + } + + + private void createGroup(HttpServletRequest request, HttpServletResponse response) throws IOException { + + Map body = readJson(request); + String groupname = asString(body.get("groupname")); + if (!validName(groupname)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.groupnameMissing", groupname)); + return; + } + List roles = asStringList(body.get("roles")); + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + if (!writableForMutation(db, response)) { + return; + } + + Group group = db.createGroup(groupname, asString(body.get("description"))); + if (group == null) { + Api.error(response, HttpServletResponse.SC_CONFLICT, "GROUP_EXISTS", + sm.getString("manager2.groupExists", groupname)); + return; + } + for (String role : roles) { + group.addRole(findOrCreateRole(db, role)); + } + save(db, response); + } + + + private void removeGroup(HttpServletRequest request, HttpServletResponse response, String groupname) + throws IOException { + + UserDatabase db = selected(request, null, response); + if (db == null) { + return; + } + Group group = db.findGroup(groupname); + if (group == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", + sm.getString("manager2.groupNotFound", groupname)); + return; + } + if (!writableForMutation(db, response)) { + return; + } + db.removeGroup(group); + save(db, response); + } + + + private void setGroupMembers(HttpServletRequest request, HttpServletResponse response, String groupname) + throws IOException { + + Map body = readJson(request); + List members = asStringList(body.get("members")); + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + Group group = db.findGroup(groupname); + if (group == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", + sm.getString("manager2.groupNotFound", groupname)); + return; + } + + List missing = new ArrayList<>(); + for (String member : members) { + if (db.findUser(member) == null) { + missing.add(member); + } + } + if (!missing.isEmpty()) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UNKNOWN_GROUP_MEMBER", + sm.getString("manager2.unknownGroupMember", String.join(", ", missing))); + return; + } + if (!writableForMutation(db, response)) { + return; + } + + Set wanted = new LinkedHashSet<>(members); + for (User user : collect(group.getUsers())) { + if (!wanted.contains(user.getUsername())) { + user.removeGroup(group); + } + } + for (String member : members) { + db.findUser(member).addGroup(group); + } + save(db, response); + } + + + private void setGroupRoles(HttpServletRequest request, HttpServletResponse response, String groupname) + throws IOException { + + Map body = readJson(request); + List roles = asStringList(body.get("roles")); + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + Group group = db.findGroup(groupname); + if (group == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", + sm.getString("manager2.groupNotFound", groupname)); + return; + } + if (!writableForMutation(db, response)) { + return; + } + group.removeRoles(); + for (String role : roles) { + group.addRole(findOrCreateRole(db, role)); + } + save(db, response); + } + + + private void createRole(HttpServletRequest request, HttpServletResponse response) throws IOException { + + Map body = readJson(request); + String rolename = asString(body.get("rolename")); + if (!validName(rolename)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.rolenameMissing", rolename)); + return; + } + + UserDatabase db = selected(request, body, response); + if (db == null) { + return; + } + if (!writableForMutation(db, response)) { + return; + } + + Role role = db.createRole(rolename, asString(body.get("description"))); + if (role == null) { + Api.error(response, HttpServletResponse.SC_CONFLICT, "ROLE_EXISTS", + sm.getString("manager2.roleExists", rolename)); + return; + } + save(db, response); + } + + + private void removeRole(HttpServletRequest request, HttpServletResponse response, String rolename) + throws IOException { + + UserDatabase db = selected(request, null, response); + if (db == null) { + return; + } + Role role = db.findRole(rolename); + if (role == null) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "ROLE_NOT_FOUND", + sm.getString("manager2.roleNotFound", rolename)); + return; + } + // Removing a role detaches it from every user and group that holds it, + // so a manager must not be able to remove a role they hold themselves: + // that would drop their own access on the next sign in. + String principalName = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null; + if (principalName != null && holdsRole(db, principalName, rolename)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "SELF_ROLE_REMOVAL", + sm.getString("manager2.selfRoleRemoval", rolename)); + return; + } + if (!writableForMutation(db, response)) { + return; + } + db.removeRole(role); + save(db, response); + } + + + // -------------------------------------------------------------- Discovery + + + /** + * Discover the user databases configured as JNDI resources of the global naming context of this server. Bindings + * whose type is {@code org.apache.catalina.UserDatabase} are resolved (other bindings are left untouched). + */ + private List> discover() { + + List> found = new ArrayList<>(); + if (server == null) { + return found; + } + javax.naming.Context jndi = server.getGlobalNamingContext(); + if (jndi == null) { + return found; + } + try { + NamingEnumeration bindings = jndi.listBindings(""); + while (bindings.hasMore()) { + String name = bindings.next().getName(); + if (name.isEmpty()) { + continue; + } + try { + Object bound = jndi.lookupLink(name); + if (bound instanceof UserDatabase udb) { + found.add(new AbstractMap.SimpleEntry<>(name, udb)); + continue; + } + if (bound instanceof Reference ref && UDB_TYPE.equals(ref.getClassName())) { + Object resolved = jndi.lookup(name); + if (resolved instanceof UserDatabase udb) { + found.add(new AbstractMap.SimpleEntry<>(name, udb)); + } + } + } catch (NamingException e) { + log(sm.getString("manager2.userDatabaseLookup", name), e); + } + } + } catch (NamingException e) { + log(sm.getString("manager2.error.users"), e); + } + return found; + } + + + /** + * Select the user database to operate on. When no name is given, the single configured database is used (or the one + * named {@code UserDatabase}, or the first one, when several are configured). Writes an error response and returns + * {@code null} on failure. + */ + private Map.Entry select(String name, List> found, + HttpServletResponse response) throws IOException { + + if (name == null || name.isEmpty()) { + if (found.size() == 1) { + return found.get(0); + } + for (Map.Entry entry : found) { + if ("UserDatabase".equals(entry.getKey())) { + return entry; + } + } + return found.get(0); + } + for (Map.Entry entry : found) { + if (entry.getKey().equals(name)) { + return entry; + } + } + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_NOT_FOUND", + sm.getString("manager2.userDatabaseNotFound", name)); + return null; + } + + + /** + * The user database selected by the {@code name} JSON body field (POST requests) or query parameter (DELETE + * requests). Writes an error response and returns {@code null} on failure. + */ + private UserDatabase selected(HttpServletRequest request, Map body, HttpServletResponse response) + throws IOException { + + String name = null; + if (body != null && body.get("name") instanceof String s && !s.isEmpty()) { + name = s; + } else { + String param = request.getParameter("name"); + name = (param == null || param.isEmpty()) ? null : param; + } + List> found = discover(); + if (found.isEmpty()) { + Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_DATABASE_MISSING", + sm.getString("manager2.userDatabaseMissing")); + return null; + } + Map.Entry selected = select(name, found, response); + return selected == null ? null : selected.getValue(); + } + + + /** + * Check that the database accepts mutations: it must not be read-only and (for the file based database) its storage + * location must be writable. Writes an error response and returns {@code false} on failure. + */ + private boolean writableForMutation(UserDatabase db, HttpServletResponse response) throws IOException { + + if (readonly(db)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_READONLY", + sm.getString("manager2.userDatabaseReadonly", db.getId())); + return false; + } + if (!writable(db)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_NOT_WRITABLE", + sm.getString("manager2.userDatabaseNotWritable", db.getId())); + return false; + } + return true; + } + + + private void save(UserDatabase db, HttpServletResponse response) throws IOException { + try { + db.save(); + } catch (Exception e) { + log(sm.getString("manager2.error.users"), e); + Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "USER_DATABASE_SAVE_FAILED", + sm.getString("manager2.userDatabaseSaveFailed", String.valueOf(e.getMessage()))); + return; + } + Api.ok(response, sm.getString("manager2.usersSaved")); + } + + + // ---------------------------------------------------------------- Helpers + + + /** + * Whether the database is configured read-only (only the file based database exposes this; other implementations + * assume writable). + */ + private static boolean readonly(UserDatabase db) { + return db instanceof MemoryUserDatabase memory && memory.getReadonly(); + } + + + /** + * Whether the database can be persisted (only the file based database exposes this; other implementations assume + * writable). + */ + private static boolean writable(UserDatabase db) { + return !(db instanceof MemoryUserDatabase memory) || memory.isWritable(); + } + + + private static Role findOrCreateRole(UserDatabase db, String rolename) { + Role role = db.findRole(rolename); + return role != null ? role : db.createRole(rolename, null); + } + + + /** + * Whether the named user holds the given role, directly or through one of their groups. + */ + private static boolean holdsRole(UserDatabase db, String username, String rolename) { + User user = db.findUser(username); + if (user == null) { + return false; + } + for (Role role : collect(user.getRoles())) { + if (rolename.equals(role.getRolename())) { + return true; + } + } + for (Group group : collect(user.getGroups())) { + for (Role role : collect(group.getRoles())) { + if (rolename.equals(role.getRolename())) { + return true; + } + } + } + return false; + } + + + /** + * {@code true} for a name that is safe in the XML storage format (the names are persisted in comma separated lists) + * and in a URL path segment (the user and group names are also addressed that way): a non empty string of letters, + * digits and the characters {@code . _ - @ +}. + */ + private static boolean validName(String name) { + if (name == null || name.isEmpty()) { + return false; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + boolean valid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || + c == '_' || c == '-' || c == '@' || c == '+'; + if (!valid) { + return false; + } + } + return true; + } + + + private static List collect(Iterator iterator) { + List result = new ArrayList<>(); + while (iterator.hasNext()) { + result.add(iterator.next()); + } + return result; + } + + + private static List collectNames(Iterator iterator, Function nameOf) { + List result = new ArrayList<>(); + for (T item : collect(iterator)) { + result.add(nameOf.apply(item)); + } + Collections.sort(result); + return result; + } + + + /** + * The servlet path plus the path info, e.g. {@code /api/users/manager1/roles}. + */ + private static String fullPath(HttpServletRequest request) { + String path = request.getServletPath(); + String info = request.getPathInfo(); + if (info != null && !info.isEmpty()) { + path = path + info; + } + return path; + } + + + /** + * The path segments after the leading {@code /api/users} or {@code /api/groups} segments, e.g. + * {@code [manager1, roles]}. + */ + private static String[] tail(String path) { + String[] segments = path.split("/"); + String[] result = new String[Math.max(0, segments.length - 3)]; + System.arraycopy(segments, 3, result, 0, result.length); + return result; + } + + + private static Map readJson(HttpServletRequest request) throws IOException { + String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (body.isBlank()) { + return new LinkedHashMap<>(); + } + try { + return new JSONParser(body).parseObject(); + } catch (Exception e) { + throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + } + } + + + private static String asString(Object value) { + return value instanceof String s && !s.isEmpty() ? s : null; + } + + + /** + * A JSON array of strings (empty when absent). + */ + private static List asStringList(Object value) { + if (value == null) { + return new ArrayList<>(); + } + if (!(value instanceof List list)) { + throw new IllegalArgumentException(sm.getString("manager2.invalidJson", "expected an array of strings")); + } + List result = new ArrayList<>(); + for (Object item : list) { + if (!(item instanceof String s) || !validName(s)) { + throw new IllegalArgumentException( + sm.getString("manager2.invalidJson", "expected an array of strings")); + } + result.add(s); + } + return result; + } + + + @Override + public String getServletInfo() { + return "Manager2 Users API"; + } +} diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java new file mode 100644 index 000000000000..ccc539b72c46 --- /dev/null +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -0,0 +1,2176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.File; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; + +import static org.apache.catalina.startup.SimpleHttpClient.CRLF; +import org.apache.catalina.Context; +import org.apache.catalina.UserDatabase; +import org.apache.catalina.core.StandardContext; +import org.apache.catalina.core.StandardEngine; +import org.apache.catalina.realm.MemoryRealm; +import org.apache.catalina.servlets.DefaultServlet; +import org.apache.catalina.startup.SimpleHttpClient; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.json.JSONParser; + + +/** + * Integration tests for the manager2 configuration API ({@code /api/config/*}). The tests deploy the + * {@code manager2.war} built by this module (via the {@code deploy} target) into a throw-away Tomcat instance and drive + * it over HTTP with {@link SimpleHttpClient}, exercising the component tree, attribute updates, structural add/remove + * of child components, and persistence to {@code server.xml} through storeconfig. + */ +public class TestManager2Config extends TomcatBaseTest { + + private static final String MANAGER2 = "/manager2"; + + private String storeBaseProp = null; + + + @After + public void clearStoreBase() { + if (storeBaseProp != null) { + System.clearProperty("manager2.store.base"); + storeBaseProp = null; + } + } + + + @Test + public void testUnauthenticatedTreeShowsLoginPage() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // The tree is a JSON API endpoint: unauthenticated requests are + // forwarded to the login page (FORM authentication). + requestRaw(client, "GET", MANAGER2 + "/api/config/tree", 200); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + + client.disconnect(); + } + + + @Test + public void testReadOnlyRoleDenied() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "status1"); + + // The config API is not part of the read-only status endpoints; it + // requires the manager-gui role. + request(client, "GET", MANAGER2 + "/api/config/tree", null, null, 403); + String token = getCsrfToken(client); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"server\",\"type\":\"alias\"}", + 403); + + client.disconnect(); + } + + + @Test + public void testMutateWithoutCsrfTokenIsRejected() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // A mutation without a CSRF token is rejected by the CSRF filter. + request(client, "POST", MANAGER2 + "/api/config/attribute", null, + "{\"id\":\"server\",\"name\":\"port\",\"value\":8005}", 403); + + client.disconnect(); + } + + + @Test + public void testTreeShape() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/config/tree", null, null, 200); + Map root = parseObject(client.getResponseBody()); + Map tree = getMap(root, "tree"); + + Assert.assertEquals("server", tree.get("type")); + Assert.assertEquals("server", tree.get("id")); + + // The default service / engine / host are present. The service and + // engine names are not fixed, so resolve them by type. + Map service = firstChildOfType(tree, "service"); + Assert.assertNotNull("Expected a service", service); + Map engine = firstChildOfType(service, "engine"); + Assert.assertNotNull("Expected an engine", engine); + Map host = findChild(engine, "host", "localhost"); + Assert.assertNotNull("Expected the localhost host", host); + + // The manager2 context is a child of the host, addressed by its + // encoded path, and is flagged as the self component. + Map self = firstChildOfType(host, "context"); + Assert.assertNotNull("Expected the manager2 context", self); + Assert.assertEquals(Boolean.TRUE, self.get("self")); + Assert.assertTrue(((String) self.get("id")).endsWith("/context/+manager2")); + + client.disconnect(); + } + + + @Test + public void testNodeDetails() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/config/node/server", null, null, 200); + Map node = parseObject(client.getResponseBody()); + Assert.assertEquals("server", node.get("id")); + Assert.assertEquals("server", node.get("type")); + Assert.assertNotNull(node.get("className")); + Assert.assertTrue(getList(node, "properties").size() > 0); + + // The self context reports the self flag and its id. + String ctxId = selfContextId(fetchTree(client)); + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxId, null, null, 200); + node = parseObject(client.getResponseBody()); + Assert.assertEquals("context", node.get("type")); + Assert.assertEquals(Boolean.TRUE, node.get("self")); + Assert.assertTrue(((String) node.get("id")).endsWith("/context/+manager2")); + + // An unknown node is reported. + request(client, "GET", MANAGER2 + "/api/config/node/nowhere", null, null, 404); + Assert.assertTrue(client.getResponseBody().contains("NOT_FOUND")); + + client.disconnect(); + } + + + @Test + public void testRootContext() throws Exception { + setup(); + + // Add a root context (the empty path) to the default host, like a + // ROOT webapp deployed from the app base. + File rootDocBase = new File(getTemporaryDirectory(), "rootapp"); + Assert.assertTrue(rootDocBase.isDirectory() || rootDocBase.mkdirs()); + getTomcatInstance().addWebapp(null, "", rootDocBase.getAbsolutePath()); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // The root context is named "/" and addressed with a bare "+" + // segment in its node id. + Map tree = fetchTree(client); + Map service = firstChildOfType(tree, "service"); + Map engine = firstChildOfType(service, "engine"); + Map hostNode = findChild(engine, "host", "localhost"); + Map rootNode = findChild(hostNode, "context", "/"); + Assert.assertNotNull("Expected the root context", rootNode); + String rootId = (String) rootNode.get("id"); + Assert.assertTrue("Unexpected root context id: " + rootId, rootId.endsWith("/context/+")); + + // The node detail of the root context resolves... + request(client, "GET", MANAGER2 + "/api/config/node/" + rootId, null, null, 200); + Map node = parseObject(client.getResponseBody()); + Assert.assertEquals("context", node.get("type")); + Assert.assertEquals("/", node.get("name")); + + // ...and so do child components of the root context. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + rootId + "\",\"type\":\"wrapper\"," + "\"name\":\"e2e-root-default\"," + + "\"servletClass\":\"org.apache.catalina.servlets.DefaultServlet\"}", + 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + rootId, null, null, 200); + node = parseObject(client.getResponseBody()); + Map wrapper = findChild(node, "wrapper", "e2e-root-default"); + Assert.assertNotNull("Expected the wrapper in the root context", wrapper); + request(client, "GET", MANAGER2 + "/api/config/node/" + wrapper.get("id"), null, null, 200); + node = parseObject(client.getResponseBody()); + Assert.assertEquals("wrapper", node.get("type")); + + client.disconnect(); + } + + + @Test + public void testAttributeRoundTripAndGuards() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String ctxId = selfContextId(fetchTree(client)); + + // Read the current (writable) sessionTimeout of the self context. + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxId, null, null, 200); + Map node = parseObject(client.getResponseBody()); + Number original = getInt(findProperty(node, "sessionTimeout").get("value")); + Assert.assertNotNull("Expected a sessionTimeout property", original); + + // Update it and read it back. + int updated = original.intValue() + 1; + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + ctxId + "\",\"name\":\"sessionTimeout\",\"value\":" + updated + "}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxId, null, null, 200); + node = parseObject(client.getResponseBody()); + Assert.assertEquals(updated, getInt(findProperty(node, "sessionTimeout").get("value")).intValue()); + + // Restore the original value. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + ctxId + "\",\"name\":\"sessionTimeout\",\"value\":" + original + "}", 200); + + // Guards ---------------------------------------------------------------- + + // A read-only attribute is rejected. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"server\",\"name\":\"portWithOffset\",\"value\":8005}", 400); + Assert.assertTrue(client.getResponseBody().contains("READ_ONLY")); + + // An unknown attribute is reported. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"server\",\"name\":\"noSuchAttribute\",\"value\":1}", 404); + Assert.assertTrue(client.getResponseBody().contains("ATTRIBUTE_NOT_FOUND")); + + // A value that does not convert to the attribute type is rejected. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + ctxId + "\",\"name\":\"sessionTimeout\",\"value\":\"not-a-number\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_VALUE")); + + // The name/path of the self context cannot be changed. The + // self-component guard takes precedence over the risky-attribute + // confirmation, with or without a confirm. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + ctxId + "\",\"name\":\"path\",\"value\":\"/renamed\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + ctxId + "\",\"name\":\"path\",\"value\":\"/renamed\",\"confirm\":\"/manager2\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + + client.disconnect(); + } + + + @Test + public void testAddRemoveChildTypes() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String serviceId = null; + String engineId = null; + String hostId = null; + String contextId = null; + String wrapperId = null; + String valveId = null; + String connectorId = null; + String executorId = "server/service/Catalina2/executor/e2e-exec"; + String aliasId = "server/service/Catalina2/engine/Catalina2/host/e2e-host/alias/e2e-alias"; + + try { + // service (creates service + engine) ------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"server\",\"type\":\"service\",\"name\":\"Catalina2\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + serviceId = "server/service/Catalina2"; + engineId = serviceId + "/engine/Catalina2"; + + // connector --------------------------------------------------- + int port = freePort(); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serviceId + + "\",\"type\":\"connector\",\"protocol\":\"HTTP/1.1\",\"port\":" + port + "}", 200); + connectorId = serviceId + "/connector/0"; + + // executor ---------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serviceId + + "\",\"type\":\"executor\",\"name\":\"e2e-exec\"," + "\"maxThreads\":10,\"minSpareThreads\":2}", + 200); + + // host -------------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + engineId + "\",\"type\":\"host\",\"name\":\"e2e-host\"}", 200); + hostId = engineId + "/host/e2e-host"; + + // alias ------------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"alias\",\"alias\":\"e2e-alias\"}", 200); + + // context ----------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"context\",\"path\":\"/e2eapp\"}", 200); + contextId = hostId + "/context/+e2eapp"; + + // wrapper ----------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + contextId + "\",\"type\":\"wrapper\",\"name\":\"e2ewrapper\"," + + "\"servletClass\":\"org.apache.catalina.servlets.DefaultServlet\"}", + 200); + wrapperId = contextId + "/wrapper/e2ewrapper"; + + // valve ------------------------------------------------------- + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + contextId + + "\",\"type\":\"valve\"," + "\"className\":\"org.apache.catalina.valves.RemoteIpValve\"}", 200); + + // Verify the whole branch is visible in the tree -------------- + request(client, "GET", MANAGER2 + "/api/config/tree", null, null, 200); + String tree = client.getResponseBody(); + Assert.assertTrue(tree.contains(serviceId)); + Assert.assertTrue(tree.contains(hostId)); + Assert.assertTrue(tree.contains(contextId)); + Assert.assertTrue(tree.contains(wrapperId)); + Assert.assertTrue(tree.contains(aliasId)); + Assert.assertTrue(tree.contains(executorId)); + Assert.assertTrue(tree.contains(connectorId)); + + // The valve id is positional; resolve it from the node detail. + request(client, "GET", MANAGER2 + "/api/config/node/" + contextId, null, null, 200); + Map ctx = parseObject(client.getResponseBody()); + valveId = findChild(ctx, "valve", "RemoteIpValve").get("id").toString(); + + // Remove each child (leaves first) --------------------------- + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + connectorId + "\",\"confirm\":\"HTTP/1.1 (port " + port + ")\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + executorId + "\",\"confirm\":\"e2e-exec\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + wrapperId + "\",\"confirm\":\"e2ewrapper\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + valveId + "\",\"confirm\":\"RemoteIpValve\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, "{\"id\":\"" + aliasId + "\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + contextId + "\",\"confirm\":\"/e2eapp\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + hostId + "\",\"confirm\":\"e2e-host\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serviceId + "\",\"confirm\":\"Catalina2\"}", 200); + + // The branch is gone. + request(client, "GET", MANAGER2 + "/api/config/tree", null, null, 200); + Assert.assertFalse(client.getResponseBody().contains(serviceId)); + } finally { + cleanup(client, token, serviceId, hostId, contextId, wrapperId, valveId, connectorId, executorId, aliasId); + } + + client.disconnect(); + } + + + @Test + public void testAddRemoveListener() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // Resolve the default host from the tree. + Map tree = fetchTree(client); + Map service = firstChildOfType(tree, "service"); + Map engine = firstChildOfType(service, "engine"); + Map hostNode = findChild(engine, "host", "localhost"); + String hostId = (String) hostNode.get("id"); + + // The node detail reports whether the component accepts a + // lifecycle listener. + request(client, "GET", MANAGER2 + "/api/config/node/" + hostId, null, null, 200); + Assert.assertEquals(Boolean.TRUE, parseObject(client.getResponseBody()).get("acceptsListener")); + + // Add a listener... + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"listener\"," + + "\"className\":\"org.apache.catalina.mbeans.GlobalResourcesLifecycleListener\"}", + 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // ...it shows up in the tree and its node detail resolves... + tree = fetchTree(client); + hostNode = findChild(firstChildOfType(firstChildOfType(tree, "service"), "engine"), "host", "localhost"); + Map listener = findChild(hostNode, "listener", "GlobalResourcesLifecycleListener"); + Assert.assertNotNull("Expected the listener in the tree", listener); + String listenerId = (String) listener.get("id"); + request(client, "GET", MANAGER2 + "/api/config/node/" + listenerId, null, null, 200); + Map listenerDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("listener", listenerDetail.get("type")); + Assert.assertEquals("GlobalResourcesLifecycleListener", listenerDetail.get("name")); + + // ...and it can be removed (no confirmation is required for + // listeners). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, "{\"id\":\"" + listenerId + "\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + hostNode = findChild(firstChildOfType(firstChildOfType(tree, "service"), "engine"), "host", "localhost"); + Assert.assertNull(findChild(hostNode, "listener", "GlobalResourcesLifecycleListener")); + + // Guards: a class that is not a lifecycle listener (or does not + // exist) is rejected... + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"listener\"," + "\"className\":\"java.lang.String\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + hostId + + "\",\"type\":\"listener\"," + "\"className\":\"org.example.NoSuchListener\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + + // ...and an alias is not a valid parent. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"alias\",\"alias\":\"e2e-lst-alias\"}", 200); + String aliasId = hostId + "/alias/e2e-lst-alias"; + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + aliasId + "\",\"type\":\"listener\"," + + "\"className\":\"org.apache.catalina.mbeans.GlobalResourcesLifecycleListener\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, "{\"id\":\"" + aliasId + "\"}", 200); + + client.disconnect(); + } + + + @Test + public void testCluster() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + Map tree = fetchTree(client); + Map engine = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + String engineId = (String) engine.get("id"); + + // No cluster by default. + Assert.assertNull(firstChildOfType(engine, "cluster")); + + // Add a cluster to the engine: this starts the channel (applying the + // cluster defaults) and attaches the cluster to the engine. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + engineId + + "\",\"type\":\"cluster\"," + "\"className\":\"org.apache.catalina.ha.tcp.SimpleTcpCluster\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // The cluster shows up with its default sub components. + tree = fetchTree(client); + engine = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + Map cluster = firstChildOfType(engine, "cluster"); + Assert.assertNotNull("Expected the cluster in the tree", cluster); + Assert.assertEquals("SimpleTcpCluster", cluster.get("name")); + String clusterId = (String) cluster.get("id"); + + Map channel = firstChildOfType(cluster, "channel"); + Assert.assertNotNull("Expected the channel in the cluster", channel); + Assert.assertNotNull("Expected a cluster valve", firstChildOfType(cluster, "clusterValve")); + Assert.assertNotNull("Expected the cluster manager", firstChildOfType(cluster, "clusterManager")); + Assert.assertNotNull("Expected a cluster listener", firstChildOfType(cluster, "clusterListener")); + String channelId = (String) channel.get("id"); + + // The channel holds the membership, sender, receiver and the default + // interceptor stack (two interceptors). + Map channelDetail = fetchNode(client, channelId); + Assert.assertEquals("channel", channelDetail.get("type")); + Assert.assertNotNull(firstChildOfType(channelDetail, "membership")); + Assert.assertNotNull(firstChildOfType(channelDetail, "sender")); + Assert.assertNotNull(firstChildOfType(channelDetail, "receiver")); + Assert.assertEquals(2, countChildrenOfType(channelDetail, "interceptor")); + + // Configure explicit attributes: the multicast membership (a + // descriptor-less class with an explicit attribute list) and the + // channel. + String membershipId = channelId + "/membership/0"; + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + membershipId + "\",\"name\":\"port\",\"value\":45599}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + channelId + "\",\"name\":\"name\",\"value\":\"e2e-cluster\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // The membership node detail reports the updated port. + Map membershipDetail = fetchNode(client, membershipId); + Assert.assertEquals("membership", membershipDetail.get("type")); + Map port = findProperty(membershipDetail, "port"); + Assert.assertEquals(45599, ((Number) port.get("value")).intValue()); + + // The configuration round-trips to server.xml (the cluster is stored + // with the configured values). The response is a JSON object holding + // the generated XML, so the xml field must be extracted first. + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + String xml = (String) parseObject(client.getResponseBody()).get("xml"); + Assert.assertTrue(xml.contains(" clusterValve = firstChildOfType(cluster, "clusterValve"); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + clusterValve.get("id") + "\",\"confirm\":\"" + clusterValve.get("name") + "\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("REMOVE_NOT_SUPPORTED")); + + // Remove the cluster (requires confirmation). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + clusterId + "\",\"confirm\":\"SimpleTcpCluster\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + engine = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + Assert.assertNull(firstChildOfType(engine, "cluster")); + + client.disconnect(); + } + + + /** + * Fetch the node detail of the component with the given id. + */ + private Map fetchNode(SimpleHttpClient client, String id) throws Exception { + request(client, "GET", MANAGER2 + "/api/config/node/" + id, null, null, 200); + return parseObject(client.getResponseBody()); + } + + + /** + * Count the direct children of a node with the given type. + */ + private static long countChildrenOfType(Map node, String type) { + List children = getList(node, "children"); + if (children == null) { + return 0; + } + long count = 0; + for (Object child : children) { + @SuppressWarnings("unchecked") + Map cm = (Map) child; + if (type.equals(cm.get("type"))) { + count++; + } + } + return count; + } + + + @Test + public void testSslHostConfig() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // A PKCS12 keystore with an RSA key for the test certificate. + File keystore = createKeystore(new File(getTemporaryDirectory(), "ssl-test")); + + // A dedicated service so that the TLS connector never is the + // connector that hosts this web application itself. + String serviceId = "server/service/CatalinaTLS"; + String connectorId = null; + int port = 0; + + try { + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"server\",\"type\":\"service\",\"name\":\"CatalinaTLS\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + port = freePort(); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serviceId + + "\",\"type\":\"connector\",\"protocol\":\"HTTP/1.1\"," + "\"port\":" + port + "}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + connectorId = serviceId + "/connector/0"; + + // The node detail of a connector reports whether TLS is + // enabled (it is not, yet). + request(client, "GET", MANAGER2 + "/api/config/node/" + connectorId, null, null, 200); + Map connectorNode = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.FALSE, connectorNode.get("sslEnabled")); + Assert.assertEquals(Boolean.FALSE, findProperty(connectorNode, "sslEnabled").get("value")); + + // Guards --------------------------------------------------- + + // An SSL host configuration can only be added to a connector. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serviceId + "/engine/CatalinaTLS\"," + "\"type\":\"sslHostConfig\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + + // The running connector cannot be switched to TLS without a + // certificate. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"sslHostConfig\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_VALUE")); + // Neither attempt leaves a trace in the tree. + Map tree = fetchTree(client); + Map connectorNodeTree = findChildById(tree, connectorId); + Assert.assertNull("Expected no SSL host configuration after the rollback", + findChild(connectorNodeTree, "sslHostConfig", "_default_")); + + // A certificate whose keystore cannot be loaded is rejected + // (the TLS configuration is validated before it is + // applied) and rolled back; the connector keeps serving + // plain HTTP without being interrupted. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"sslHostConfig\"," + + "\"certificate\":{\"type\":\"RSA\"," + + "\"certificateKeystoreFile\":\"/does/not/exist.p12\"," + + "\"certificateKeystorePassword\":\"changeit\"}}", + 400); + Assert.assertTrue(client.getResponseBody().contains("ADD_FAILED")); + Assert.assertNull("Expected no SSL host configuration after the rollback", + findChild(findChildById(fetchTree(client), connectorId), "sslHostConfig", "_default_")); + + // An unknown certificate type is rejected. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + connectorId + + "\",\"type\":\"sslHostConfig\"," + "\"certificate\":{\"type\":\"BOGUS\"}}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_NAME")); + + // A certificate is only a child of an SSL host configuration. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"certificate\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + + // Add ------------------------------------------------------ + + // Add the SSL host configuration together with its + // certificate. The running connector picks up TLS without + // being interrupted and serves TLS from now on. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"sslHostConfig\"," + + "\"certificate\":{\"type\":\"RSA\",\"certificateKeystoreFile\":\"" + keystore.getPath() + + "\",\"certificateKeystorePassword\":\"changeit\"," + + "\"certificateKeyAlias\":\"tomcat\",\"certificateKeystoreType\":\"PKCS12\"}}", + 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + String sslHostConfigId = connectorId + "/sslHostConfig/_default_"; + String certificateId = sslHostConfigId + "/certificate/0"; + + // The TLS branch is visible in the tree. + tree = fetchTree(client); + connectorNodeTree = findChildById(tree, connectorId); + Assert.assertNotNull("Expected the connector in the tree", connectorNodeTree); + Map sslNode = findChild(connectorNodeTree, "sslHostConfig", "_default_"); + Assert.assertNotNull("Expected the SSL host configuration in the tree", sslNode); + Assert.assertEquals(sslHostConfigId, sslNode.get("id")); + Map certNode = findChild(sslNode, "certificate", "RSA"); + Assert.assertNotNull("Expected the certificate in the tree", certNode); + Assert.assertEquals(certificateId, certNode.get("id")); + + // The node details of the TLS components resolve... + request(client, "GET", MANAGER2 + "/api/config/node/" + sslHostConfigId, null, null, 200); + Map sslDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("sslHostConfig", sslDetail.get("type")); + Assert.assertEquals("_default_", sslDetail.get("name")); + Assert.assertEquals(Boolean.TRUE, sslDetail.get("isDefault")); + Map protocolsProp = findProperty(sslDetail, "protocols"); + Assert.assertNotNull("Expected a protocols property", protocolsProp); + Assert.assertEquals(Boolean.TRUE, protocolsProp.get("writable")); + request(client, "GET", MANAGER2 + "/api/config/node/" + certificateId, null, null, 200); + Map certDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("certificate", certDetail.get("type")); + Assert.assertEquals("RSA", certDetail.get("name")); + Assert.assertEquals("RSA", findProperty(certDetail, "type").get("value")); + Assert.assertEquals(keystore.getPath(), findProperty(certDetail, "certificateKeystoreFile").get("value")); + + // ...as does the connector, which now reports TLS enabled. + request(client, "GET", MANAGER2 + "/api/config/node/" + connectorId, null, null, 200); + connectorNode = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, connectorNode.get("sslEnabled")); + + // The connector really serves TLS. + int status = httpsGet(port, "/"); + Assert.assertTrue("Expected an HTTP response over TLS, got " + status, status >= 200 && status < 600); + + // An attribute of the SSL host configuration can be updated. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + sslHostConfigId + "\",\"name\":\"protocols\"," + "\"value\":\"TLSv1.2+TLSv1.3\"}", + 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + sslHostConfigId, null, null, 200); + sslDetail = parseObject(client.getResponseBody()); + // The protocol set is order independent. + String protocols = (String) findProperty(sslDetail, "protocols").get("value"); + Assert.assertEquals(new java.util.HashSet<>(List.of("TLSv1.2", "TLSv1.3")), + new java.util.HashSet<>(List.of(protocols.split("\\+")))); + + // Read-only TLS attributes are protected. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + certificateId + "\",\"name\":\"type\",\"value\":\"DSA\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("READ_ONLY")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + sslHostConfigId + "\",\"name\":\"hostName\",\"value\":\"x\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("READ_ONLY")); + + // A second certificate can be added (and is applied at once + // on the running connector) ... For a certificate the + // "type" field carries the certificate type, not a child + // component type. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + sslHostConfigId + "\",\"type\":\"RSA\"," + "\"certificateKeystoreFile\":\"" + + keystore.getPath() + "\",\"certificateKeystorePassword\":\"changeit\"," + + "\"certificateKeyAlias\":\"tomcat\",\"certificateKeystoreType\":\"PKCS12\"}", + 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // ...and removed again. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + sslHostConfigId + "/certificate/1\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // The last certificate of a running, TLS enabled connector + // cannot be removed (the connector would not be able to + // start anymore). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, "{\"id\":\"" + certificateId + "\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("SSL_LAST_CERTIFICATE")); + + // The TLS configuration is part of the stored server.xml. + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + String xml = (String) parseObject(client.getResponseBody()).get("xml"); + Assert.assertTrue(xml.contains("= 200 && plainStatus < 600); + } finally { + // Best effort cleanup (ignore failures, including assertion + // errors: the failure of the test itself is reported). + try { + if (connectorId != null) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + connectorId + "\"," + "\"confirm\":\"HTTP/1.1 (port " + port + ")\"}", 200); + } + } catch (Throwable e) { + // Best effort. + } + try { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serviceId + "\",\"confirm\":\"CatalinaTLS\"}", 200); + } catch (Throwable e) { + // Best effort. + } + } + + client.disconnect(); + } + + + @Test + public void testRealm() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String hostId = null; + String ctxId = null; + try { + // Display -------------------------------------------------- + + Map tree = fetchTree(client); + Map service = firstChildOfType(tree, "service"); + Map engine = firstChildOfType(service, "engine"); + String engineId = (String) engine.get("id"); + Map hostNode = findChild(engine, "host", "localhost"); + hostId = (String) hostNode.get("id"); + + // The engine has its own realm (see setup()) and it is shown + // as a child of the engine. + Map engineRealm = findChild(engine, "realm", "MemoryRealm"); + Assert.assertNotNull("Expected the engine realm", engineRealm); + String engineRealmId = (String) engineRealm.get("id"); + Assert.assertEquals(engineId + "/realm/0", engineRealmId); + + // A container without its own realm does not show one (it + // inherits the parent realm, which is not its child). + Assert.assertNull("Expected no realm on the host", findChild(hostNode, "realm", "MemoryRealm")); + + // The node detail of a realm exposes its modeler properties. + request(client, "GET", MANAGER2 + "/api/config/node/" + engineRealmId, null, null, 200); + Map realmDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("realm", realmDetail.get("type")); + Assert.assertEquals("MemoryRealm", realmDetail.get("name")); + Assert.assertEquals(Boolean.FALSE, realmDetail.get("acceptsSubRealm")); + Assert.assertNotNull(findProperty(realmDetail, "allRolesMode")); + + // Add ------------------------------------------------------ + + // A (sub) realm can only be added to a container or to a + // combined realm. A plain realm does not accept sub realms. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + engineRealmId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + // An unknown realm class is rejected. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"realm\"," + "\"className\":\"org.example.NoSuchRealm\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + + // A realm can be added to a container ... + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + hostId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + // ...but a container holds at most one realm of its own. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + hostId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("DUPLICATE")); + + tree = fetchTree(client); + hostNode = findChild(firstChildOfType(firstChildOfType(tree, "service"), "engine"), "host", "localhost"); + Map hostRealm = findChild(hostNode, "realm", "MemoryRealm"); + Assert.assertNotNull("Expected the host realm", hostRealm); + String hostRealmId = (String) hostRealm.get("id"); + + // Configure ------------------------------------------------ + + // An attribute of the realm can be updated ... + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + hostRealmId + "\",\"name\":\"allRolesMode\"," + "\"value\":\"authOnly\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + request(client, "GET", MANAGER2 + "/api/config/node/" + hostRealmId, null, null, 200); + realmDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("authOnly", findProperty(realmDetail, "allRolesMode").get("value")); + // ...and an invalid value is rejected by the realm itself. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + hostRealmId + "\",\"name\":\"allRolesMode\"," + "\"value\":\"bogus\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("SET_FAILED")); + + // Sub realms ------------------------------------------------ + + // A context gets a combined realm (a LockOutRealm, which is a + // CombinedRealm) with a sub realm. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"context\"," + "\"path\":\"/realmapp\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + hostNode = findChild(firstChildOfType(firstChildOfType(tree, "service"), "engine"), "host", "localhost"); + ctxId = (String) findChild(hostNode, "context", "/realmapp").get("id"); + + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.LockOutRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + String ctxRealmId = ctxId + "/realm/0"; + String subRealmId = ctxRealmId + "/realm/0"; + + // The combined realm is shown with its sub realms. + tree = fetchTree(client); + Map ctxNode = findChildById(tree, ctxId); + Assert.assertNotNull("Expected the context in the tree", ctxNode); + Map ctxRealm = findChild(ctxNode, "realm", "LockOutRealm"); + Assert.assertNotNull("Expected the context realm", ctxRealm); + Map subRealm = findChild(ctxRealm, "realm", "MemoryRealm"); + Assert.assertNull("Expected no sub realm, yet", subRealm); + + // The node detail of a combined realm reports that it accepts + // sub realms. + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxRealmId, null, null, 200); + realmDetail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, realmDetail.get("acceptsSubRealm")); + + // A sub realm can be added ... + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxRealmId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + // ...a second one, and both are shown. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxRealmId + + "\",\"type\":\"realm\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + ctxNode = findChildById(tree, ctxId); + ctxRealm = findChild(ctxNode, "realm", "LockOutRealm"); + Assert.assertEquals("Expected two sub realms", 2, ((List) ctxRealm.get("children")).size()); + Assert.assertNotNull(findChild(ctxRealm, "realm", "MemoryRealm")); + + // The sub realm's node detail resolves. + request(client, "GET", MANAGER2 + "/api/config/node/" + subRealmId, null, null, 200); + realmDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("realm", realmDetail.get("type")); + Assert.assertEquals("MemoryRealm", realmDetail.get("name")); + Assert.assertEquals(Boolean.FALSE, realmDetail.get("acceptsSubRealm")); + + // The combined realm and its sub realms are part of the + // stored server.xml. + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + String xml = (String) parseObject(client.getResponseBody()).get("xml"); + Assert.assertTrue(xml.contains("org.apache.catalina.realm.LockOutRealm")); + Assert.assertTrue(xml.contains("org.apache.catalina.realm.MemoryRealm")); + + // Remove --------------------------------------------------- + + // A sub realm can be removed (and, with it, its effect). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + subRealmId + "\",\"confirm\":\"MemoryRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + ctxNode = findChildById(tree, ctxId); + ctxRealm = findChild(ctxNode, "realm", "LockOutRealm"); + Assert.assertEquals("Expected one sub realm left", 1, ((List) ctxRealm.get("children")).size()); + + // A directly attached realm can be removed when the container + // falls back to a parent realm ... + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxRealmId + "\",\"confirm\":\"LockOutRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + tree = fetchTree(client); + Assert.assertNull("Expected no realm on the context", + findChild(findChildById(tree, ctxId), "realm", "LockOutRealm")); + + // ...but not when the container would be left without a realm. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + engineRealmId + "\",\"confirm\":\"MemoryRealm\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("LAST_REALM")); + + // The host realm (the host falls back to the engine realm) + // can be removed. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + hostRealmId + "\",\"confirm\":\"MemoryRealm\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + } finally { + // Best effort cleanup (ignore failures, including assertion + // errors: the failure of the test itself is reported). + try { + if (ctxId != null) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxId + "\",\"confirm\":\"/realmapp\"}", 200); + } + } catch (Throwable e) { + // Best effort. + } + try { + if (hostId != null) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + hostId + "/realm/0\",\"confirm\":\"MemoryRealm\"}", 200); + } + } catch (Throwable e) { + // Best effort. + } + } + + client.disconnect(); + } + + + @Test + public void testContextComponents() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String hostId = null; + String ctxId = null; + try { + // A context to operate on (the self context is guarded). + Map tree = fetchTree(client); + Map engine = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + String engineId = (String) engine.get("id"); + Map hostNode = findChild(engine, "host", "localhost"); + hostId = (String) hostNode.get("id"); + + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"context\"," + "\"path\":\"/comptest\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // Display -------------------------------------------------- + + // A running context always has a manager, a resource root, + // a loader and a cookie processor (created at context start) + // and they are all shown as children of the context. + tree = fetchTree(client); + Map engine2 = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + Map hostNode2 = findChild(engine2, "host", "localhost"); + Map ctxEntry = findChild(hostNode2, "context", "/comptest"); + Assert.assertNotNull("Expected the new context in the tree", ctxEntry); + ctxId = (String) ctxEntry.get("id"); + Map ctxNode = findChildById(tree, ctxId); + Map manager = findChild(ctxNode, "manager", "StandardManager"); + Assert.assertNotNull("Expected the manager", manager); + String managerId = (String) manager.get("id"); + Assert.assertEquals(ctxId + "/manager/0", managerId); + Assert.assertEquals("STARTED", manager.get("state")); + Map resources = findChild(ctxNode, "resources", "StandardRoot"); + Assert.assertNotNull("Expected the resources", resources); + String resourcesId = (String) resources.get("id"); + Map loader = findChild(ctxNode, "loader", "WebappLoader"); + Assert.assertNotNull("Expected the loader", loader); + String loaderId = (String) loader.get("id"); + Map cookie = findChild(ctxNode, "cookieProcessor", "Rfc6265CookieProcessor"); + Assert.assertNotNull("Expected the cookie processor", cookie); + String cookieId = (String) cookie.get("id"); + + // The manager shows its session id generator as a child. + Map generator = findChild(manager, "sessionIdGenerator", "StandardSessionIdGenerator"); + Assert.assertNotNull("Expected the session id generator", generator); + String generatorId = (String) generator.get("id"); + Assert.assertEquals(managerId + "/sessionIdGenerator/0", generatorId); + + // The node detail of each component exposes its properties: + // the manager and the resources through their modeler + // descriptor, the others through the explicit attribute list. + request(client, "GET", MANAGER2 + "/api/config/node/" + managerId, null, null, 200); + Map detail = parseObject(client.getResponseBody()); + Assert.assertEquals("manager", detail.get("type")); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "maxActive").get("writable")); + request(client, "GET", MANAGER2 + "/api/config/node/" + resourcesId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "allowLinking").get("writable")); + request(client, "GET", MANAGER2 + "/api/config/node/" + loaderId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "delegate").get("writable")); + request(client, "GET", MANAGER2 + "/api/config/node/" + cookieId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "sameSiteCookies").get("writable")); + request(client, "GET", MANAGER2 + "/api/config/node/" + generatorId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "sessionIdLength").get("writable")); + + // Configure ------------------------------------------------ + + // The attributes of each component can be updated and the + // new value is reported by the node detail. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + managerId + "\",\"name\":\"maxActive\",\"value\":42}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + managerId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(42, ((Number) findProperty(detail, "maxActive").get("value")).intValue()); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + loaderId + "\",\"name\":\"delegate\",\"value\":true}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + loaderId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "delegate").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + cookieId + "\",\"name\":\"sameSiteCookies\"," + "\"value\":\"LAX\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + cookieId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals("LAX", findProperty(detail, "sameSiteCookies").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + generatorId + "\",\"name\":\"sessionIdLength\",\"value\":40}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + generatorId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals(40, ((Number) findProperty(detail, "sessionIdLength").get("value")).intValue()); + + // An invalid value is rejected by the component itself. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + cookieId + "\",\"name\":\"sameSiteCookies\"," + "\"value\":\"bogus\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("SET_FAILED")); + + // Add (replace) --------------------------------------------- + + // A context holds exactly one of each of these components, so + // adding one replaces the current instance: the attribute + // value set on the old instance is gone afterwards. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"manager\"," + "\"className\":\"org.apache.catalina.session.StandardManager\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + managerId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals("Expected the default maxActive of the new manager", 0, + ((Number) findProperty(detail, "maxActive").get("value")).intValue()); + Assert.assertEquals("STARTED", detail.get("state")); + // The new manager has its own (default) session id generator. + tree = fetchTree(client); + Map newManager = findChild(findChildById(tree, ctxId), "manager", "StandardManager"); + Assert.assertNotNull(findChild(newManager, "sessionIdGenerator", "StandardSessionIdGenerator")); + + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + ctxId + "\",\"type\":\"cookieProcessor\"," + + "\"className\":\"org.apache.tomcat.util.http.Rfc6265CookieProcessor\"}", + 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + cookieId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals("Expected the default sameSiteCookies of the new processor", "UNSET", + findProperty(detail, "sameSiteCookies").get("value")); + + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + managerId + "\",\"type\":\"sessionIdGenerator\"," + + "\"className\":\"org.apache.catalina.util.StandardSessionIdGenerator\"}", + 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + generatorId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals("Expected the default sessionIdLength of the new generator", 16, + ((Number) findProperty(detail, "sessionIdLength").get("value")).intValue()); + + // A class that does not implement the expected interface (or + // that does not exist) is rejected. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"manager\"," + "\"className\":\"org.apache.catalina.realm.MemoryRealm\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"loader\"," + "\"className\":\"org.example.NoSuchLoader\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + managerId + + "\",\"type\":\"sessionIdGenerator\"," + "\"className\":\"org.example.NoSuchGenerator\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + + // The parent must be a context (a manager for the session id + // generator). + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + engineId + + "\",\"type\":\"manager\"," + "\"className\":\"org.apache.catalina.session.StandardManager\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + ctxId + "\",\"type\":\"sessionIdGenerator\"," + + "\"className\":\"org.apache.catalina.util.StandardSessionIdGenerator\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + + // Replacing the manager (or the loader) of this web + // application's own context is refused: it would destroy the + // admin session (or the classes of the running application). + // (The self context id is derived directly: the context path + // "/" encodes as "+", so "/manager2" becomes "+manager2".) + String selfCtxId = hostId + "/context/" + MANAGER2.replace("/", "+"); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + selfCtxId + + "\",\"type\":\"manager\"," + "\"className\":\"org.apache.catalina.session.StandardManager\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + selfCtxId + + "\",\"type\":\"loader\"," + "\"className\":\"org.apache.catalina.loader.WebappLoader\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + + // The resources of a running context cannot be replaced... + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"resources\"," + "\"className\":\"org.apache.catalina.webresources.StandardRoot\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("CONTEXT_RUNNING")); + + // ...but they can on a stopped context. + Context context = (Context) getTomcatInstance().getHost().findChild("/comptest"); + context.stop(); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxId + + "\",\"type\":\"resources\"," + "\"className\":\"org.apache.catalina.webresources.StandardRoot\"}", + 200); + context.start(); + request(client, "GET", MANAGER2 + "/api/config/node/" + resourcesId, null, null, 200); + detail = parseObject(client.getResponseBody()); + Assert.assertEquals("StandardRoot", detail.get("name")); + Assert.assertEquals("STARTED", detail.get("state")); + + // Remove --------------------------------------------------- + + // These components are required by the context (or the + // manager) and cannot be removed - only replaced. + for (String id : List.of(managerId, resourcesId, loaderId, cookieId, generatorId)) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + id + "\",\"confirm\":\"x\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("REQUIRED_COMPONENT")); + } + } finally { + // Best effort cleanup (ignore failures, including assertion + // errors: the failure of the test itself is reported). + try { + if (ctxId != null) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxId + "\",\"confirm\":\"/comptest\"}", 200); + } + } catch (Throwable e) { + // Best effort. + } + } + + client.disconnect(); + } + + + @Test + public void testNamingResources() throws Exception { + setup(true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String serverNrId = "server/namingResources/0"; + File usersXml = new File(getTemporaryDirectory(), "conf/tomcat-users.xml"); + Assert.assertTrue("Expected the test user file", usersXml.isFile()); + + String ctxId = null; + try { + // --------------------------- Server level ---------------------- + + // The server exposes a single, global naming resources node. + Map tree = fetchTree(client); + Map serverNr = findChild(tree, "namingResources", "NamingResourcesImpl"); + Assert.assertNotNull("Expected the global naming resources", serverNr); + Assert.assertEquals(serverNrId, serverNr.get("id")); + request(client, "GET", MANAGER2 + "/api/config/node/" + serverNrId, null, null, 200); + Map nrDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("namingResources", nrDetail.get("type")); + Assert.assertEquals(Boolean.TRUE, nrDetail.get("global")); + + // Add a global UserDatabase with a first party factory. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"resource\"," + + "\"name\":\"UserDatabaseTest\",\"jndiType\":\"org.apache.catalina.UserDatabase\"," + + "\"factory\":\"org.apache.catalina.users.MemoryUserDatabaseFactory\"," + + "\"params\":{\"pathname\":\"" + usersXml.getAbsolutePath() + "\",\"readonly\":\"true\"}}", + 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + // The resource is bound in the live global naming context and + // loaded the configured users. + Object lookedUp = getTomcatInstance().getServer().getGlobalNamingContext().lookup("UserDatabaseTest"); + Assert.assertTrue("Expected a UserDatabase to be bound", lookedUp instanceof UserDatabase); + Assert.assertNotNull(((UserDatabase) lookedUp).findUser("manager1")); + + // The tree shows the resource and its node detail exposes the + // closed factory options (with the set values) as parameters. + tree = fetchTree(client); + Map dbRes = findChild(findChild(tree, "namingResources", "NamingResourcesImpl"), "resource", + "UserDatabaseTest"); + Assert.assertNotNull("Expected the resource in the tree", dbRes); + Assert.assertEquals(serverNrId + "/resource/UserDatabaseTest", dbRes.get("id")); + String dbId = (String) dbRes.get("id"); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Map dbDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("resource", dbDetail.get("type")); + Assert.assertEquals(Boolean.TRUE, findProperty(dbDetail, "name").get("writable")); + Assert.assertEquals(Boolean.TRUE, findProperty(dbDetail, "pathname").get("param")); + Assert.assertEquals(usersXml.getAbsolutePath(), findProperty(dbDetail, "pathname").get("value")); + Assert.assertEquals("true", findProperty(dbDetail, "readonly").get("value")); + // A closed factory option that is not set is still listed. + Assert.assertNotNull(findProperty(dbDetail, "watchSource")); + + // Add the other global entry types. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"environment\"," + + "\"name\":\"genv\",\"jndiType\":\"java.lang.Integer\",\"value\":\"42\"}", + 200); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"ejb\"," + + "\"name\":\"gejb\",\"jndiType\":\"org.example.Home\"," + "\"home\":\"org.example.Home\"}", + 200); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"localEjb\"," + + "\"name\":\"glejb\",\"jndiType\":\"org.example.Local\"," + + "\"local\":\"org.example.Local\"}", + 200); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"serviceRef\"," + + "\"name\":\"gservice\",\"jndiType\":\"org.example.Svc\"," + + "\"interface\":\"org.example.Svc\",\"displayname\":\"test\"}", + 200); + tree = fetchTree(client); + Map serverNr2 = findChild(tree, "namingResources", "NamingResourcesImpl"); + Assert.assertNotNull(findChild(serverNr2, "environment", "genv")); + Assert.assertNotNull(findChild(serverNr2, "ejb", "gejb")); + Assert.assertNotNull(findChild(serverNr2, "localEjb", "glejb")); + Assert.assertNotNull(findChild(serverNr2, "serviceRef", "gservice")); + + // The generic string parameters of every JNDI entry type (the + // ResourceBase property map) are shown in the entry detail and + // can be added, edited and removed there. + String genvId = serverNrId + "/environment/genv"; + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + genvId + "\",\"name\":\"envKey\",\"value\":\"envValue\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + genvId, null, null, 200); + Map genvDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("envValue", findProperty(genvDetail, "envKey").get("value")); + Assert.assertEquals(Boolean.TRUE, findProperty(genvDetail, "envKey").get("param")); + // An ejb and a web service reference expose their parameters too. + String gejbId = serverNrId + "/ejb/gejb"; + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + gejbId + "\",\"name\":\"ejbKey\",\"value\":\"ejbValue\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + gejbId, null, null, 200); + Assert.assertEquals("ejbValue", findProperty(parseObject(client.getResponseBody()), "ejbKey").get("value")); + String gsvcId = serverNrId + "/serviceRef/gservice"; + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + gsvcId + "\",\"name\":\"svcKey\",\"value\":\"svcValue\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + gsvcId, null, null, 200); + Assert.assertEquals("svcValue", findProperty(parseObject(client.getResponseBody()), "svcKey").get("value")); + // A parameter can be edited and then removed (by clearing it). + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + genvId + "\",\"name\":\"envKey\",\"value\":\"envValue2\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + genvId, null, null, 200); + Assert.assertEquals("envValue2", + findProperty(parseObject(client.getResponseBody()), "envKey").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + genvId + "\",\"name\":\"envKey\",\"value\":\"\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + genvId, null, null, 200); + Assert.assertNull(findProperty(parseObject(client.getResponseBody()), "envKey")); + + // Guards (server level). + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serverNrId + + "\",\"type\":\"resource\"," + "\"name\":\"UserDatabaseTest\",\"jndiType\":\"java.lang.String\"}", + 409); + Assert.assertTrue(client.getResponseBody().contains("DUPLICATE")); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"resource\",\"name\":\"noType\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("MISSING_FIELD")); + // Resource links are not part of the global naming environment. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"resourceLink\"," + + "\"name\":\"glink\",\"jndiType\":\"java.lang.String\",\"global\":\"x\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serverNrId + "\",\"type\":\"resource\"," + + "\"name\":\"badfactory\",\"jndiType\":\"javax.sql.DataSource\"," + + "\"factory\":\"org.example.NoSuchFactory\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + + // A parameter update re-registers the entry, so the live JNDI + // environment reflects the change (and stays bound). + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"readonly\",\"value\":\"false\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Assert.assertEquals("false", findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); + Assert.assertNotNull(getTomcatInstance().getServer().getGlobalNamingContext().lookup("UserDatabaseTest")); + + // Renaming requires a type-to-confirm and rebinds the resource. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"name\"," + "\"value\":\"UserDatabaseRenamed\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("CONFIRM_REQUIRED")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, "{\"id\":\"" + dbId + + "\",\"name\":\"name\"," + "\"value\":\"UserDatabaseRenamed\",\"confirm\":\"UserDatabaseTest\"}", + 200); + Assert.assertNotNull( + getTomcatInstance().getServer().getGlobalNamingContext().lookup("UserDatabaseRenamed")); + try { + getTomcatInstance().getServer().getGlobalNamingContext().lookup("UserDatabaseTest"); + Assert.fail("Expected the old name to be unbound"); + } catch (Exception e) { + Assert.assertTrue(e instanceof javax.naming.NameNotFoundException); + } + String dbId2 = serverNrId + "/resource/UserDatabaseRenamed"; + + // A free form parameter can be added and cleared. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId2 + "\",\"name\":\"extraKey\",\"value\":\"hello\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId2, null, null, 200); + Assert.assertEquals("hello", findProperty(parseObject(client.getResponseBody()), "extraKey").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId2 + "\",\"name\":\"extraKey\",\"value\":\"\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId2, null, null, 200); + Assert.assertNull(findProperty(parseObject(client.getResponseBody()), "extraKey")); + + // The global naming resources node is required (cannot be removed). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serverNrId + "\",\"confirm\":\"x\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("REQUIRED_COMPONENT")); + + // The global naming resources round trip to server.xml, + // including the web service reference (ServiceRef). + File storeBase = new File(getTemporaryDirectory(), "store-naming-base"); + File storeConf = new File(storeBase, "conf"); + Assert.assertTrue(storeConf.mkdirs()); + addDeleteOnTearDown(storeBase); + setStoreBase(storeBase); + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + String xml = (String) parseObject(client.getResponseBody()).get("xml"); + Assert.assertTrue(xml.contains(" engine = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + String hostId = (String) findChild(engine, "host", "localhost").get("id"); + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"context\",\"path\":\"/comptest\"}", 200); + tree = fetchTree(client); + Map engine2 = firstChildOfType(firstChildOfType(tree, "service"), "engine"); + Map ctxEntry = findChild(findChild(engine2, "host", "localhost"), "context", "/comptest"); + Assert.assertNotNull("Expected the new context", ctxEntry); + ctxId = (String) ctxEntry.get("id"); + String ctxNrId = ctxId + "/namingResources/0"; + + // A context naming resources node is not global. + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxNrId, null, null, 200); + Assert.assertEquals(Boolean.FALSE, parseObject(client.getResponseBody()).get("global")); + + // A context may hold resource links (unlike the server). + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + ctxNrId + "\",\"type\":\"resourceLink\"," + + "\"name\":\"link/db\",\"jndiType\":\"org.apache.catalina.UserDatabase\"," + + "\"global\":\"UserDatabaseRenamed\"}", + 200); + // A context resource that resolves to the default (first party) + // data source factory from its type. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + ctxNrId + "\",\"type\":\"resource\"," + + "\"name\":\"jdbc/ctxds\",\"jndiType\":\"javax.sql.DataSource\"," + + "\"params\":{\"url\":\"jdbc:h2:mem:ctx\",\"username\":\"sa\"," + "\"maxTotal\":\"10\"}}", + 200); + + StandardContext context = (StandardContext) getTomcatInstance().getHost().findChild("/comptest"); + javax.naming.Context envCtx = context.getNamingContextListener().getEnvContext(); + // The link is configured to point to the global resource. + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxNrId + "/resourceLink/link+db", null, null, 200); + Assert.assertEquals("UserDatabaseRenamed", + findProperty(parseObject(client.getResponseBody()), "global").get("value")); + // The resource is bound in the live context environment. + Object ds = envCtx.lookup("jdbc/ctxds"); + Assert.assertTrue("Expected a DataSource to be bound", ds instanceof javax.sql.DataSource); + + tree = fetchTree(client); + Map ctxNr = findChild(findChildById(tree, ctxId), "namingResources", "NamingResourcesImpl"); + Assert.assertNotNull(findChild(ctxNr, "resourceLink", "link/db")); + Assert.assertNotNull(findChild(ctxNr, "resource", "jdbc/ctxds")); + String dsId = ctxNrId + "/resource/jdbc+ctxds"; + request(client, "GET", MANAGER2 + "/api/config/node/" + dsId, null, null, 200); + Map dsDetail = parseObject(client.getResponseBody()); + Assert.assertEquals("jdbc:h2:mem:ctx", findProperty(dsDetail, "url").get("value")); + Assert.assertEquals("10", findProperty(dsDetail, "maxTotal").get("value")); + + // A parameter update re-registers the entry (the live re-lookup + // still resolves). + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dsId + "\",\"name\":\"maxTotal\",\"value\":\"5\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dsId, null, null, 200); + Assert.assertEquals("5", findProperty(parseObject(client.getResponseBody()), "maxTotal").get("value")); + Assert.assertTrue(envCtx.lookup("jdbc/ctxds") instanceof javax.sql.DataSource); + + // Context guard: duplicate name. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + ctxNrId + + "\",\"type\":\"resource\"," + "\"name\":\"jdbc/ctxds\",\"jndiType\":\"java.lang.String\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("DUPLICATE")); + + // Removing an entry (type-to-confirm) unbinds it. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + dsId + "\",\"confirm\":\"jdbc/ctxds\"}", 200); + try { + envCtx.lookup("jdbc/ctxds"); + Assert.fail("Expected the resource to be unbound"); + } catch (Exception e) { + Assert.assertTrue(e instanceof javax.naming.NameNotFoundException); + } + } finally { + // Best effort cleanup (ignore failures: the test failure itself + // is reported). + try { + if (ctxId != null) { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxId + "\",\"confirm\":\"/comptest\"}", 200); + } + } catch (Throwable e) { + // Best effort. + } + String[][] globals = { { "resource", "UserDatabaseRenamed" }, { "environment", "genv" }, { "ejb", "gejb" }, + { "localEjb", "glejb" }, { "serviceRef", "gservice" } }; + for (String[] g : globals) { + try { + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serverNrId + "/" + g[0] + "/" + g[1] + "\",\"confirm\":\"" + g[1] + "\"}", + 200); + } catch (Throwable e) { + // Best effort. + } + } + } + + client.disconnect(); + } + + + @Test + public void testAddValidationErrors() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + Map tree = fetchTree(client); + Map service = firstChildOfType(tree, "service"); + Assert.assertNotNull("Expected a service", service); + String serviceId = (String) service.get("id"); + String serviceName = (String) service.get("name"); + String ctxId = selfContextId(tree); + + // An unsupported child type. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"server\",\"type\":\"bogus\"}", + 400); + Assert.assertTrue(client.getResponseBody().contains("UNSUPPORTED_TYPE")); + + // A wrong parent (a host under a context is invalid). + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + ctxId + "\",\"type\":\"host\",\"name\":\"x\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + + // A duplicate service name. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"server\",\"type\":\"service\",\"name\":\"" + serviceName + "\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("DUPLICATE")); + + // An invalid connector port. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serviceId + "\",\"type\":\"connector\",\"port\":0}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_VALUE")); + + // An executor whose minimum exceeds its maximum. + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serviceId + + "\",\"type\":\"executor\",\"name\":\"e2e-bad\"," + "\"maxThreads\":10,\"minSpareThreads\":20}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_VALUE")); + + client.disconnect(); + } + + + @Test + public void testRemoveGuards() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + Map tree = fetchTree(client); + Map service = firstChildOfType(tree, "service"); + Assert.assertNotNull("Expected a service", service); + String serviceId = (String) service.get("id"); + String serviceName = (String) service.get("name"); + String ctxId = selfContextId(tree); + String hostId = hostIdOf(ctxId); + + // The last service cannot be removed. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serviceId + "\",\"confirm\":\"" + serviceName + "\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("LAST_SERVICE")); + + // The self context and self host cannot be removed. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxId + "\",\"confirm\":\"/manager2\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + hostId + "\",\"confirm\":\"localhost\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + + // The basic (first) valve of a pipeline cannot be removed. + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + ctxId + "/valve/0\",\"confirm\":\"x\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BASIC_COMPONENT")); + + client.disconnect(); + } + + + @Test + public void testStorePreviewDoesNotWrite() throws Exception { + setup(); + + File storeBase = new File(getTemporaryDirectory(), "store-preview-base"); + File conf = new File(storeBase, "conf"); + Assert.assertTrue(conf.mkdirs()); + addDeleteOnTearDown(storeBase); + setStoreBase(storeBase); + + int before = conf.list().length; + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + Map res = parseObject(client.getResponseBody()); + String xml = (String) res.get("xml"); + Assert.assertNotNull(xml); + Assert.assertTrue(xml.contains(" files = (List) res.get("files"); + Assert.assertNotNull(files); + Assert.assertTrue(files.stream().anyMatch(f -> String.valueOf(f).endsWith("context.xml"))); + Assert.assertEquals(Boolean.TRUE, res.get("restartsManager")); + + // The preview must not have written anything to the conf directory. + Assert.assertEquals(before, conf.list().length); + + client.disconnect(); + } + + + @Test + public void testStoreWritesFileAndBackup() throws Exception { + setup(); + + File storeBase = new File(getTemporaryDirectory(), "store-base"); + File conf = new File(storeBase, "conf"); + Assert.assertTrue(conf.mkdirs()); + addDeleteOnTearDown(storeBase); + setStoreBase(storeBase); + // A pre-existing server.xml so that a backup can be created. + try (PrintWriter pw = new PrintWriter(new File(conf, "server.xml"), StandardCharsets.UTF_8)) { + pw.println(""); + } + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String ctxId = selfContextId(fetchTree(client)); + String hostId = hostIdOf(ctxId); + + // Add a recognizable component that must end up in the stored file. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"alias\",\"alias\":\"store-test-alias\"}", 200); + + try { + request(client, "POST", MANAGER2 + "/api/config/store", token, "{}", 200); + Map res = parseObject(client.getResponseBody()); + Assert.assertEquals(Boolean.TRUE, res.get("ok")); + Assert.assertEquals("conf/server.xml", res.get("file")); + String backup = (String) res.get("backup"); + Assert.assertNotNull("Expected a backup file name", backup); + + // The live state (including the added alias) was written. + String written = readFile(new File(conf, "server.xml")); + Assert.assertTrue(written.contains("store-test-alias")); + Assert.assertTrue(written.contains(" true); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + try { + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + + /** + * Issue a plain (non-TLS) HTTP GET against the given port and return the HTTP status code (or a negative value when + * no HTTP response was received at all). + */ + private static int httpGetPlain(int port, String path) { + try { + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new URL( + "http://localhost:" + port + path).openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + try { + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } catch (Exception e) { + return -1; + } + } + + + /** + * Create a PKCS12 keystore with a self signed RSA certificate (key alias "tomcat", passwords "changeit") using the + * keytool of the JDK the tests run on. + */ + private File createKeystore(File dir) throws Exception { + Assert.assertTrue(dir.isDirectory() || dir.mkdirs()); + File keytool = new File(new File(System.getProperty("java.home"), "bin"), "keytool"); + File keystore = new File(dir, "e2e-keystore.p12"); + ProcessBuilder builder = new ProcessBuilder(keytool.getAbsolutePath(), "-genkeypair", "-alias", "tomcat", + "-keyalg", "RSA", "-keysize", "2048", "-storetype", "PKCS12", "-keystore", keystore.getAbsolutePath(), + "-storepass", "changeit", "-keypass", "changeit", "-dname", "CN=localhost", "-validity", "30"); + builder.redirectErrorStream(true); + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + int exit = process.waitFor(); + Assert.assertEquals("keytool failed: " + output, 0, exit); + Assert.assertTrue(keystore.isFile()); + addDeleteOnTearDown(keystore); + return keystore; + } + + + private static String readFile(File file) throws Exception { + return new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + + // ------------------------------------------------------- JSON navigation + + + @SuppressWarnings("unchecked") + private static Map parseObject(String json) throws Exception { + JSONParser parser = new JSONParser(json); + parser.setNativeNumbers(true); + return (Map) parser.parseObject(); + } + + + @SuppressWarnings("unchecked") + private static Map getMap(Map map, String key) { + return (Map) map.get(key); + } + + + @SuppressWarnings("unchecked") + private static List getList(Map map, String key) { + return (List) map.get(key); + } + + + private static Number getInt(Object value) { + return (Number) value; + } + + + /** + * Find a direct child node (from a node's {@code children} list) by type and name. + */ + private static Map findChild(Map node, String type, String name) { + List children = getList(node, "children"); + if (children == null) { + return null; + } + for (Object child : children) { + @SuppressWarnings("unchecked") + Map cm = (Map) child; + if (type.equals(cm.get("type")) && name.equals(cm.get("name"))) { + return cm; + } + } + return null; + } + + + /** + * Find a node with the given id anywhere in the tree. + */ + private static Map findChildById(Map node, String id) { + if (id.equals(node.get("id"))) { + return node; + } + List children = getList(node, "children"); + if (children != null) { + for (Object child : children) { + @SuppressWarnings("unchecked") + Map cm = (Map) child; + Map found = findChildById(cm, id); + if (found != null) { + return found; + } + } + } + return null; + } + + + /** + * Find the first direct child node (from a node's {@code children} list) of the given type. + */ + private static Map firstChildOfType(Map node, String type) { + List children = getList(node, "children"); + if (children == null) { + return null; + } + for (Object child : children) { + @SuppressWarnings("unchecked") + Map cm = (Map) child; + if (type.equals(cm.get("type"))) { + return cm; + } + } + return null; + } + + + /** + * Fetch the component tree. + */ + private Map fetchTree(SimpleHttpClient client) throws Exception { + request(client, "GET", MANAGER2 + "/api/config/tree", null, null, 200); + return getMap(parseObject(client.getResponseBody()), "tree"); + } + + + /** + * Resolve the id of the manager2 (self) context from the tree. The service and engine names are not fixed (they + * depend on how the Tomcat instance was created), so the path must not be hard-coded. + */ + private static String selfContextId(Map tree) { + Map service = firstChildOfType(tree, "service"); + Assert.assertNotNull("Expected a service", service); + Map engine = firstChildOfType(service, "engine"); + Assert.assertNotNull("Expected an engine", engine); + Map host = firstChildOfType(engine, "host"); + Assert.assertNotNull("Expected a host", host); + Map context = firstChildOfType(host, "context"); + Assert.assertNotNull("Expected the manager2 context", context); + Assert.assertEquals(Boolean.TRUE, context.get("self")); + return (String) context.get("id"); + } + + + /** + * The id of the host that owns the context with the given id. + */ + private static String hostIdOf(String contextId) { + int ix = contextId.lastIndexOf('/'); + return contextId.substring(0, contextId.lastIndexOf('/', ix - 1)); + } + + + /** + * Find a property entry (from a node's {@code properties} list) by name. + */ + private static Map findProperty(Map node, String name) { + List properties = getList(node, "properties"); + if (properties == null) { + return null; + } + for (Object property : properties) { + @SuppressWarnings("unchecked") + Map p = (Map) property; + if (name.equals(p.get("name"))) { + return p; + } + } + return null; + } + + + // ----------------------------------------------------------------------- + + + private void setup() throws Exception { + setup(false); + } + + + private void setup(boolean withNaming) throws Exception { + Tomcat tomcat = getTomcatInstance(); + tomcat.setAddDefaultWebXmlToWebapp(false); + if (withNaming) { + // Enable JNDI naming so that JNDI resources can be registered + // in (and looked up from) the global naming context of the + // server, like the file based one of the default server.xml. + tomcat.enableNaming(); + } + + // A conf/tomcat-users.xml with the test users so that MemoryRealm + // instances can be started, mirroring a real CATALINA_BASE layout. + File conf = new File(getTemporaryDirectory(), "conf"); + Assert.assertTrue(conf.isDirectory() || conf.mkdirs()); + try (PrintWriter pw = new PrintWriter(new File(conf, "tomcat-users.xml"), StandardCharsets.UTF_8)) { + pw.println(""); + pw.println(" "); + pw.println(" "); + pw.println(" "); + pw.println(" "); + pw.println(""); + } + + // The programmatic Tomcat API installs a private internal realm that + // storeconfig cannot serialise. Use the same realm type as the + // production server.xml; it loads the users from the file above. + ((StandardEngine) tomcat.getEngine()).setRealm(new MemoryRealm()); + + File webapps = new File(getBuildDirectory(), "webapps"); + File appDir = new File(webapps, "manager2"); + File appWar = new File(webapps, "manager2.war"); + File source = appDir.exists() ? appDir : appWar; + Assert.assertTrue("manager2 webapp missing - run 'ant deploy' first", source.exists()); + Context manager2 = tomcat.addWebapp(null, MANAGER2, source.getAbsolutePath()); + addDefaultServlet(manager2); + manager2.addMimeMapping("css", "text/css"); + manager2.addMimeMapping("js", "text/javascript"); + manager2.addMimeMapping("html", "text/html"); + + tomcat.start(); + } + + + private void addDefaultServlet(Context context) { + Tomcat.addServlet(context, "default", new DefaultServlet()); + context.addServletMapping("/", "default"); + } + + + /** + * Establish a login session for the given user via the FORM login. + */ + private void login(SimpleHttpClient client, String user) throws Exception { + client.setRequest(new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + + Assert.assertNotNull("Expected a session to be established", client.getSessionId()); + + String body = "j_username=" + user + "&j_password=secret"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + + Assert.assertEquals(303, client.getStatusCode()); + } + + + private String loginAndGetToken(SimpleHttpClient client, String user) throws Exception { + login(client, user); + request(client, "GET", MANAGER2 + "/api/info", null, null, 200); + String token = getCsrfToken(client); + Assert.assertNotNull("Expected an X-CSRF-Token header", token); + return token; + } + + + private void request(SimpleHttpClient client, String method, String path, String token, String body, + int expectedStatus) throws Exception { + StringBuilder request = new StringBuilder(); + request.append(method).append(' ').append(path).append(" HTTP/1.1").append(CRLF); + request.append("Host: localhost:").append(getPort()).append(CRLF); + if (client.getSessionId() != null) { + request.append("Cookie: JSESSIONID=").append(client.getSessionId()).append(CRLF); + } + if (token != null) { + request.append("X-CSRF-Token: ").append(token).append(CRLF); + } + if (body != null) { + request.append("Content-Type: application/json").append(CRLF); + request.append("Content-Length: ").append(body.getBytes(StandardCharsets.UTF_8).length).append(CRLF); + } + request.append("Connection: Close").append(CRLF); + request.append(CRLF); + if (body != null) { + request.append(body); + } + client.setRequest(new String[] { request.toString() }); + client.connect(); + client.processRequest(true); + if (client.getStatusCode() != expectedStatus) { + System.out.println("DBG unexpected: " + method + " " + path + " status=" + client.getStatusCode() + + " body=" + client.getResponseBody().substring(0, Math.min(300, client.getResponseBody().length()))); + } + Assert.assertEquals(expectedStatus, client.getStatusCode()); + } + + + private void requestRaw(SimpleHttpClient client, String method, String path, int expectedStatus) throws Exception { + StringBuilder request = new StringBuilder(); + request.append(method).append(' ').append(path).append(" HTTP/1.1").append(CRLF); + request.append("Host: localhost:").append(getPort()).append(CRLF); + if (client.getSessionId() != null) { + request.append("Cookie: JSESSIONID=").append(client.getSessionId()).append(CRLF); + } + request.append("Connection: Close").append(CRLF); + request.append(CRLF); + client.setRequest(new String[] { request.toString() }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(expectedStatus, client.getStatusCode()); + } + + + private String getCsrfToken(SimpleHttpClient client) { + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("x-csrf-token: ")) { + return header.substring("X-CSRF-Token: ".length()); + } + } + return null; + } + + + private static class TestClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } +} diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java new file mode 100644 index 000000000000..c52f6034fc59 --- /dev/null +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -0,0 +1,1312 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.Assert; +import org.junit.Test; + +import static org.apache.catalina.startup.SimpleHttpClient.CRLF; +import org.apache.catalina.Context; +import org.apache.catalina.Server; +import org.apache.catalina.servlets.DefaultServlet; +import org.apache.catalina.startup.HostConfig; +import org.apache.catalina.startup.SimpleHttpClient; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.catalina.users.MemoryUserDatabase; +import org.apache.catalina.valves.AccessLogValve; +import org.apache.tomcat.util.descriptor.web.ContextResource; + +/** + * Integration tests for the manager2 web application. The tests deploy the {@code manager2.war} built by this module + * (via the {@code deploy} target) into a throw-away Tomcat instance and drive it over HTTP with + * {@link SimpleHttpClient}, exercising FORM login, CSRF protection, role based access control and the JSON API. + */ +public class TestManager2Webapp extends TomcatBaseTest { + + private static final String MANAGER2 = "/manager2"; + private static final String TESTAPP = "/testapp"; + + @Test + public void testUnauthenticatedSeesLoginPage() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Request the SPA entry point: FORM authentication forwards to the + // login page. + client.setRequest(new String[] { "GET " + MANAGER2 + "/ HTTP/1.1" + CRLF, "Host: localhost:" + getPort() + CRLF, + "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + + // API requests are protected as well. + client.setRequest(new String[] { "GET " + MANAGER2 + "/api/apps HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + + client.disconnect(); + } + + + @Test + public void testContextRootRedirectsToTrailingSlash() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // The context root without a trailing slash is redirected to the + // trailing-slash form, so that the browser resolves the + // application's relative URLs (CSS, JS, images) against the + // context instead of the server root. + client.setRequest(new String[] { "GET " + MANAGER2 + " HTTP/1.1" + CRLF, "Host: localhost:" + getPort() + CRLF, + "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(302, client.getStatusCode()); + String location = null; + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("location: ")) { + location = header.substring("Location: ".length()); + } + } + Assert.assertEquals(MANAGER2 + "/", location); + + client.disconnect(); + } + + + @Test + public void testLoginCsrfAndInfo() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // Read-only endpoint; also issues the CSRF token. + client.setRequest( + new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, "Host: localhost:" + getPort() + CRLF, + "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("javaRuntimeVersion")); + String token = getCsrfToken(client); + Assert.assertNotNull("Expected an X-CSRF-Token header", token); + Assert.assertTrue("Expected a 32+ character CSRF token", token.length() >= 32); + + client.disconnect(); + } + + + @Test + public void testStaticAssetsArePublic() throws Exception { + setup(false); + + // Regression test: a security constraint with the pattern "/" matches + // every request in the context. When the SPA shell was protected with + // such a constraint, the login page's own CSS and JS requests were + // sent through FORM authentication (leaving the login page unstyled) + // and the saved request pointed at an asset file. + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + requestRaw(client, "GET", MANAGER2 + "/css/manager2.css", 200); + boolean css = false; + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("content-type:") && header.contains("text/css")) { + css = true; + } + } + Assert.assertTrue("Expected text/css for the stylesheet", css); + + requestRaw(client, "GET", MANAGER2 + "/js/main.js", 200); + + client.disconnect(); + } + + + @Test + public void testPostLoginRedirectsToAppRoot() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Establish a session (and the saved request) via a protected API. + client.setRequest(new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + + // Submit the login form. + String body = "j_username=manager1&j_password=secret"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + + Assert.assertEquals(303, client.getStatusCode()); + + // The post-login redirect must land at the application root, not at + // the saved API request (or, historically, at a CSS/JS file). + String location = null; + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("location:")) { + location = header.substring("location:".length()).trim(); + } + } + Assert.assertEquals(MANAGER2 + "/", location); + + // The redirect target is the SPA shell. + requestRaw(client, "GET", MANAGER2 + "/", 200); + Assert.assertTrue(client.getResponseBody().contains("shell-main")); + + client.disconnect(); + } + + + @Test + public void testPostLoginReturnsToSpaRoute() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Unauthenticated deep link to an SPA route: the server gates it to + // the login page and must remember the route for the post-login + // redirect (this is what the SPA does when the session expires + // while the user is on that page). + requestRaw(client, "GET", MANAGER2 + "/apps", 200); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + Assert.assertNotNull("Expected a session to be established", client.getSessionId()); + + // Submit the login form with the same session. + String body = "j_username=manager1&j_password=secret"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + + Assert.assertEquals(303, client.getStatusCode()); + + // The post-login redirect must return the user to the page they + // were on, not to the application root. + String location = null; + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("location:")) { + location = header.substring("location:".length()).trim(); + } + } + Assert.assertEquals(MANAGER2 + "/apps", location); + + // The redirect target serves the SPA shell. + requestRaw(client, "GET", MANAGER2 + "/apps", 200); + Assert.assertTrue(client.getResponseBody().contains("shell-main")); + + client.disconnect(); + } + + + @Test + public void testAuthenticatedRootServesShell() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The context root serves the SPA shell to authenticated users. + requestRaw(client, "GET", MANAGER2 + "/", 200); + Assert.assertTrue(client.getResponseBody().contains("shell-main")); + Assert.assertTrue(client.getResponseBody().contains("js/main.js")); + + // The SPA deep-link routes serve the shell as well (deep links survive + // a reload). + requestRaw(client, "GET", MANAGER2 + "/apps", 200); + Assert.assertTrue(client.getResponseBody().contains("shell-main")); + + client.disconnect(); + } + + + @Test + public void testWrongPasswordShowsErrorPage() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Show the login page (establishes the session). + requestRaw(client, "GET", MANAGER2 + "/", 200); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + + // Submit the login form with a bad password. + String body = "j_username=manager1&j_password=wrong"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + + // The error page is the login page with the error message shown. + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + Assert.assertTrue(client.getResponseBody().contains("Sign in failed")); + + client.disconnect(); + } + + + @Test + public void testWrongPasswordStaysUnauthenticated() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Establish a session via a protected resource. + client.setRequest(new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertNotNull(client.getSessionId()); + + // Submit the login form with a bad password. + String body = "j_username=manager1&j_password=wrong"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + Assert.assertNotEquals(303, client.getStatusCode()); + + // Still unauthenticated: API requests are forwarded to the login + // page. + client.setRequest( + new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, "Host: localhost:" + getPort() + CRLF, + "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("j_security_check")); + + client.disconnect(); + } + + + @Test + public void testMutateWithoutCsrfTokenIsRejected() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "POST", MANAGER2 + "/api/apps/testapp/stop?path=%2Ftestapp", null, "{}", 403); + + client.disconnect(); + } + + + @Test + public void testAppLifecycleAndList() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // The list of applications includes the test app. + request(client, "GET", MANAGER2 + "/api/apps", null, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"path\":\"" + TESTAPP + "\"")); + + // The test app is deployed and serving. + requestRaw(client, "GET", TESTAPP + "/", 200); + + // Stop the test app. + request(client, "POST", MANAGER2 + "/api/apps/testapp/stop?path=%2Ftestapp", token, "{}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + requestRaw(client, "GET", TESTAPP + "/", 404); + + // Start it again. + request(client, "POST", MANAGER2 + "/api/apps/testapp/start?path=%2Ftestapp", token, "{}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + requestRaw(client, "GET", TESTAPP + "/", 200); + + // Undeploy it. + request(client, "DELETE", MANAGER2 + "/api/apps/testapp?path=%2Ftestapp", token, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + requestRaw(client, "GET", TESTAPP + "/", 404); + + client.disconnect(); + } + + + @Test + public void testStatusEndpoints() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/status", null, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"jvm\"")); + Assert.assertTrue(client.getResponseBody().contains("\"memory\"")); + + request(client, "GET", MANAGER2 + "/api/status/workers", null, null, 200); + Assert.assertTrue(client.getResponseBody().startsWith("[")); + + request(client, "GET", MANAGER2 + "/api/status/apps/testapp?path=%2Ftestapp", null, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"wrappers\"")); + Assert.assertTrue(client.getResponseBody().contains("\"state\":\"STARTED\"")); + + client.disconnect(); + } + + + @Test + public void testStatusHistory() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The StatusApi servlet is load-on-startup, so the background + // collection already runs at deployment time. The response reports + // the configured defaults (10 minute window, 2 second tick). + request(client, "GET", MANAGER2 + "/api/status/history", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"windowMs\":600000")); + Assert.assertTrue(body.contains("\"tickMs\":2000")); + Assert.assertTrue(body.contains("\"samples\"")); + + // Wait for at least two more ticks: every sample after the first has + // a baseline, so its rates must be non-null. + Thread.sleep(4500); + request(client, "GET", MANAGER2 + "/api/status/history", null, null, 200); + body = client.getResponseBody(); + int sampleCount = body.split("\"ts\":", -1).length - 1; + Assert.assertTrue("Expected at least 2 samples, found " + sampleCount, sampleCount >= 2); + int nullRates = body.split("\"rps\":null", -1).length - 1; + Assert.assertTrue("Expected at least one sample with a non-null rate", nullRates < sampleCount); + + client.disconnect(); + } + + + @Test + public void testHostsEndpoint() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/hosts", null, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"name\":\"localhost\"")); + // The default host is up, so it must be reported as started + // (state STARTED is not the same thing as the raw state name the + // UI used to match on). + Assert.assertTrue(client.getResponseBody().contains("\"state\":\"STARTED\"")); + Assert.assertTrue(client.getResponseBody().contains("\"started\":true")); + Assert.assertTrue(client.getResponseBody().contains("\"self\":true")); + + client.disconnect(); + } + + + @Test + public void testResourcesEndpointDropsStatusLine() throws Exception { + setup(false, true); + + File xmlFile = new File(getTemporaryDirectory(), "tomcat-users-resources.xml"); + writeUserDatabaseXml(xmlFile, ""); + addUserDatabase("UserDatabase", xmlFile, false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/resources", null, null, 200); + String body = client.getResponseBody(); + // The registered resource is listed with its class name (the + // resolved implementation class of the factory). + Assert.assertTrue(body.contains("UserDatabase:org.apache.catalina.users.MemoryUserDatabase")); + // The human readable status line the classic manager renders first + // ("OK - Listed global resources of all types") is not part of the + // resource list. + Assert.assertFalse(body.contains("Listed global resources")); + + client.disconnect(); + } + + + @Test + public void testReadOnlyRoleCannotMutate() throws Exception { + setup(true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "status1"); + + // Read access to the status endpoints is allowed. + request(client, "GET", MANAGER2 + "/api/status", null, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"jvm\"")); + + // Mutations are not (the CSRF filter and the security constraints + // both reject the request). + String token = getCsrfToken(client); + request(client, "POST", MANAGER2 + "/api/apps/testapp/stop?path=%2Ftestapp", token, "{}", 403); + + client.disconnect(); + } + + + @Test + public void testDeployFromServerWar() throws Exception { + setup(false); + + File warFile = createTestWar(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // Deploy from a server-side WAR location. + String body = "{\"path\":\"/deployed\",\"war\":\"file://" + warFile.getAbsolutePath() + "\"}"; + request(client, "POST", MANAGER2 + "/api/apps/deploy", token, body, 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + requestRaw(client, "GET", "/deployed/", 200); + + // Undeploy again. + request(client, "DELETE", MANAGER2 + "/api/apps/deployed?path=%2Fdeployed", token, null, 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + client.disconnect(); + } + + + @Test + public void testSessionsFlow() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Create a session in the test app. + requestRaw(client, "GET", TESTAPP + "/session.jsp", 200); + + String token = loginAndGetToken(client, "manager1"); + + // List sessions. + request(client, "GET", MANAGER2 + "/api/apps/testapp/sessions?path=%2Ftestapp", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"sessions\":[")); + int idStart = body.indexOf("\"id\":\""); + Assert.assertTrue("Expected at least one session", idStart >= 0); + String sessionId = body.substring(idStart + 6, body.indexOf('"', idStart + 6)); + + // Session detail includes attributes. + request(client, "GET", MANAGER2 + "/api/apps/testapp/sessions/" + sessionId + "?path=%2Ftestapp", null, null, + 200); + Assert.assertTrue(client.getResponseBody().contains("\"name\":\"testAttr\"")); + + // Invalidate the session. + request(client, "POST", MANAGER2 + "/api/apps/testapp/sessions/invalidate?path=%2Ftestapp", token, + "{\"ids\":[\"" + sessionId + "\"]}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"count\":1")); + + client.disconnect(); + } + + + @Test + public void testLogFilesListAndSeverityFilter() throws Exception { + setup(false); + + File logsDir = new File(getTemporaryDirectory(), "logs"); + Assert.assertTrue(logsDir.isDirectory() || logsDir.mkdirs()); + writeLogFile(new File(logsDir, "catalina.2026-01-01.log"), + "01-Jan-2026 10:00:00.001 INFO [main] org.apache.test.A.start Server starting\n" + + "01-Jan-2026 10:00:01.002 WARNING [http] org.apache.test.B.warn Something fishy\n" + + "01-Jan-2026 10:00:02.003 SEVERE [http] org.apache.test.C.fail It broke\n" + + "\tat org.apache.test.C.fail(C.java:10)\n" + + "01-Jan-2026 10:00:03.004 INFO [main] org.apache.test.D.done Server done\n"); + writeLogFile(new File(logsDir, "localhost.2026-01-02.log"), + "{\"time\": \"2026-01-02T10:00:00.001Z\", \"level\": \"INFO\", \"thread\": \"main\"," + + " \"class\": \"org.apache.test.A\", \"method\": \"start\", \"message\": \"boot\"}\n" + + "{\"time\": \"2026-01-02T10:00:01.002Z\", \"level\": \"SEVERE\", \"thread\": \"http\"," + + " \"class\": \"org.apache.test.C\", \"method\": \"fail\", \"message\": \"boom\"," + + " \"throwable\": [\"java.lang.IllegalStateException: boom\"," + + " \" at org.apache.test.C.fail(C.java:10)\"]}\n"); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The list contains both files with their detected formats and not + // the access log files. + request(client, "GET", MANAGER2 + "/api/logs", null, null, 200); + String list = client.getResponseBody(); + Assert.assertTrue(list.contains("\"name\":\"catalina.2026-01-01.log\"")); + Assert.assertTrue(list.contains("\"name\":\"localhost.2026-01-02.log\"")); + Assert.assertFalse(list.contains("access_log")); + + // The plain text log: all four records, the level counts and the + // stack trace attached to the SEVERE record. + request(client, "GET", MANAGER2 + "/api/logs/file?name=catalina.2026-01-01.log", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"format\":\"text\"")); + Assert.assertTrue(body.contains("\"total\":4")); + Assert.assertTrue(body.contains("\"SEVERE\":1")); + Assert.assertTrue(body.contains("\"WARNING\":1")); + Assert.assertTrue(body.contains("\"INFO\":2")); + Assert.assertTrue(body.contains("at org.apache.test.C.fail")); + // The records are ordered from most recent to least recent. + Assert.assertTrue(body.indexOf("Server done") < body.indexOf("Server starting")); + + // The severity filter keeps only the SEVERE record. + request(client, "GET", MANAGER2 + "/api/logs/file?name=catalina.2026-01-01.log&level=SEVERE", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("It broke")); + Assert.assertFalse(body.contains("Server done")); + + // The JSON log is parsed as well and can be filtered by level. + request(client, "GET", MANAGER2 + "/api/logs/file?name=localhost.2026-01-02.log&level=SEVERE", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"format\":\"json\"")); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("boom")); + + // A free text search works across the fields. + request(client, "GET", MANAGER2 + "/api/logs/file?name=catalina.2026-01-01.log&search=fishy", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("Something fishy")); + + // Invalid file names are rejected. + request(client, "GET", MANAGER2 + "/api/logs/file?name=..%2Fweb.xml", null, null, 400); + + client.disconnect(); + } + + + @Test + public void testAccessLogTextPatternAndFilters() throws Exception { + setup(false); + + File logsDir = new File(getTemporaryDirectory(), "logs"); + Assert.assertTrue(logsDir.isDirectory() || logsDir.mkdirs()); + writeLogFile(new File(logsDir, "localhost_access_log.2026-01-01.txt"), + "127.0.0.1 - - [01/Jan/2026:10:00:00 +0000] \"GET /ok HTTP/1.1\" 200 100 ABCDEF\n" + + "127.0.0.1 - - [01/Jan/2026:10:00:01 +0000] \"GET /missing HTTP/1.1\" 404 55 -\n" + + "127.0.0.1 - manager1 [01/Jan/2026:10:00:02 +0000] \"POST /submit HTTP/1.1\" 500 10 GHIJKL\n"); + + // Configure an access log valve with a pattern that also logs the + // session ID: the available fields (and filters) must follow the + // configured pattern. + AccessLogValve valve = new AccessLogValve(); + valve.setDirectory(new File(getTemporaryDirectory(), "valve-logs").getAbsolutePath()); + valve.setPrefix("valve"); + valve.setPattern("%h %l %u %t \"%r\" %s %b %S"); + getTomcatInstance().getHost().getPipeline().addValve(valve); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The list reports the file, the pattern and the derived fields. + request(client, "GET", MANAGER2 + "/api/access-log", null, null, 200); + String list = client.getResponseBody(); + Assert.assertTrue(list.contains("\"name\":\"localhost_access_log.2026-01-01.txt\"")); + Assert.assertTrue(list.contains("%S")); + Assert.assertTrue(list.contains("sessionId")); + + // All three records are parsed; the method, path and protocol are + // derived from the request line and the status is a number. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-01.txt", null, null, + 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"format\":\"text\"")); + Assert.assertTrue(body.contains("\"total\":3")); + Assert.assertTrue(body.contains("\"2xx\":1")); + Assert.assertTrue(body.contains("\"4xx\":1")); + Assert.assertTrue(body.contains("\"5xx\":1")); + Assert.assertTrue(body.contains("\"method\":\"GET\"")); + Assert.assertTrue(body.contains("\"path\":\"/missing\"")); + Assert.assertTrue(body.contains("\"protocol\":\"HTTP/1.1\"")); + Assert.assertTrue(body.contains("\"statusCode\":404")); + Assert.assertTrue(body.contains("\"sessionId\":\"ABCDEF\"")); + Assert.assertTrue(body.contains("\"user\":\"manager1\"")); + // The records are ordered from most recent to least recent. + Assert.assertTrue(body.indexOf("/submit") < body.indexOf("/ok")); + + // Filter by status class. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-01.txt&status=4xx", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("/missing")); + + // Filter by method. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-01.txt&method=POST", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("manager1")); + + // Filter by user. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-01.txt&user=manager1", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + + // Filter by session ID. + request(client, "GET", + MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-01.txt&session=GHIJKL", null, null, + 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("/submit")); + + client.disconnect(); + } + + + @Test + public void testAccessLogJsonAndSessionFilter() throws Exception { + setup(false); + + File logsDir = new File(getTemporaryDirectory(), "logs"); + Assert.assertTrue(logsDir.isDirectory() || logsDir.mkdirs()); + writeLogFile(new File(logsDir, "localhost_access_log.2026-01-02.txt"), + "{\"host\": \"127.0.0.1\", \"user\": \"manager1\"," + + " \"time\": \"[02/Jan/2026:10:00:00 +0000]\", \"method\": \"GET\"," + + " \"path\": \"/\", \"protocol\": \"HTTP/1.1\"," + + " \"statusCode\": \"200\", \"size\": \"10\", \"sessionId\": \"AAAA-1111\"}\n" + + "{\"host\": \"127.0.0.1\", \"user\": \"-\"," + + " \"time\": \"[02/Jan/2026:10:00:01 +0000]\", \"method\": \"GET\"," + + " \"path\": \"/forbidden\", \"protocol\": \"HTTP/1.1\"," + + " \"statusCode\": \"403\", \"size\": \"5\", \"sessionId\": \"CCCC-2222\"}\n"); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The JSON access log is detected and parsed; the status is + // converted to a number and the "-" marker to null. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-02.txt", null, null, + 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"format\":\"json\"")); + Assert.assertTrue(body.contains("\"total\":2")); + Assert.assertTrue(body.contains("\"sessionId\"")); + Assert.assertTrue(body.contains("\"statusCode\":403")); + Assert.assertTrue(body.contains("\"user\":null")); + + // Filter by session ID. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-02.txt&session=AAAA", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("\"user\":\"manager1\"")); + + // Filter by status class. + request(client, "GET", MANAGER2 + "/api/access-log/file?name=localhost_access_log.2026-01-02.txt&status=4xx", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"matched\":1")); + Assert.assertTrue(body.contains("/forbidden")); + + client.disconnect(); + } + + + @Test + public void testLogApiReadOnlyRoleDenied() throws Exception { + setup(true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "status1"); + + // The log API is not part of the read-only status endpoints. + request(client, "GET", MANAGER2 + "/api/logs", null, null, 403); + request(client, "GET", MANAGER2 + "/api/access-log", null, null, 403); + + client.disconnect(); + } + + + @Test + public void testUsersApiWithoutUserDatabase() throws Exception { + setup(false); + + // The programmatic test instance has no UserDatabase JNDI resource + // configured, so the API reports that no user database is available. + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + request(client, "GET", MANAGER2 + "/api/users", null, null, 404); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"error\":\"USER_DATABASE_MISSING\"")); + + // Mutations are rejected for the same reason. + request(client, "POST", MANAGER2 + "/api/users", token, "{\"username\":\"x\",\"password\":\"y\"}", 404); + + client.disconnect(); + } + + + @Test + public void testUserDatabaseReadonlyRejectsMutations() throws Exception { + setup(false, true); + + File xmlFile = new File(getTemporaryDirectory(), "tomcat-users-readonly.xml"); + writeUserDatabaseXml(xmlFile, " \n" + + " \n"); + addUserDatabase("Users", xmlFile, true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // The list reports the database as read-only. + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"name\":\"Users\"")); + Assert.assertTrue(body.contains("\"readonly\":true")); + Assert.assertTrue(body.contains("\"username\":\"manager1\"")); + Assert.assertTrue(body.contains("\"rolename\":\"manager-gui\"")); + + // Mutations are rejected while the database is read-only. + request(client, "POST", MANAGER2 + "/api/users", token, "{\"username\":\"bob\",\"password\":\"secret\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("USER_DATABASE_READONLY")); + + client.disconnect(); + } + + + @Test + public void testUsersAndGroupsCrudAndPersistence() throws Exception { + setup(false, true); + + File xmlFile = new File(getTemporaryDirectory(), "tomcat-users-test.xml"); + writeUserDatabaseXml(xmlFile, " \n" + + " \n"); + addUserDatabase("Users", xmlFile, false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // Create a user with a new role; the change is persisted to the XML + // file. + request(client, "POST", MANAGER2 + "/api/users", token, + "{\"username\":\"alice\",\"password\":\"wonderland\",\"fullName\":\"Alice\"," + + "\"roles\":[\"manager-gui\",\"ops\"]}", + 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + Assert.assertTrue(readFile(xmlFile).contains("username=\"alice\"")); + Assert.assertTrue(readFile(xmlFile).contains("rolename=\"ops\"")); + + // The list shows the user, its roles and the new role definition. + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"username\":\"alice\"")); + Assert.assertTrue(body.contains("\"fullName\":\"Alice\"")); + Assert.assertTrue(body.contains("\"hasPassword\":true")); + Assert.assertTrue(body.contains("\"rolename\":\"ops\"")); + + // The password can be changed and is persisted. + request(client, "POST", MANAGER2 + "/api/users/alice/password", token, "{\"password\":\"newsecret\"}", 200); + Assert.assertTrue(readFile(xmlFile).contains("password=\"newsecret\"")); + + // The roles of a user can be replaced. + request(client, "POST", MANAGER2 + "/api/users/alice/roles", token, "{\"roles\":[\"ops\"]}", 200); + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + Assert.assertTrue(client.getResponseBody() + .contains("\"username\":\"alice\",\"fullName\":\"Alice\",\"hasPassword\":true,\"roles\":[\"ops\"]")); + + // Groups can be created, their members and roles set, and removed. + request(client, "POST", MANAGER2 + "/api/groups", token, + "{\"groupname\":\"staff\",\"description\":\"The staff\",\"roles\":[\"ops\"]}", 200); + request(client, "POST", MANAGER2 + "/api/groups/staff/members", token, "{\"members\":[\"alice\",\"manager1\"]}", + 200); + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"groupname\":\"staff\"")); + Assert.assertTrue(body.contains("\"description\":\"The staff\"")); + Assert.assertTrue(body.contains("\"members\":[\"alice\",\"manager1\"]")); + // alice inherits the group role in addition to her direct roles. + Assert.assertTrue(body.contains("\"effectiveRoles\":[\"manager-gui\",\"ops\"]")); + // The group is persisted. + Assert.assertTrue(readFile(xmlFile).contains("groupname=\"staff\"")); + + // Unknown members are rejected. + request(client, "POST", MANAGER2 + "/api/groups/staff/members", token, "{\"members\":[\"nobody\"]}", 400); + Assert.assertTrue(client.getResponseBody().contains("UNKNOWN_GROUP_MEMBER")); + + // The group roles can be replaced. + request(client, "POST", MANAGER2 + "/api/groups/staff/roles", token, "{\"roles\":[\"manager-status\"]}", 200); + + // Removing the group detaches it from its members. + request(client, "DELETE", MANAGER2 + "/api/groups/staff", token, null, 200); + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + body = client.getResponseBody(); + Assert.assertFalse(body.contains("\"groupname\":\"staff\"")); + Assert.assertFalse(body.contains("\"members\":[\"alice\"")); + + // Roles can be created explicitly (with a description) and are + // persisted to the XML file. + request(client, "POST", MANAGER2 + "/api/roles", token, + "{\"rolename\":\"audit\",\"description\":\"Audit role\"}", 200); + Assert.assertTrue(readFile(xmlFile).contains("rolename=\"audit\"")); + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"rolename\":\"audit\",\"description\":\"Audit role\"")); + + // An existing role name is rejected. + request(client, "POST", MANAGER2 + "/api/roles", token, "{\"rolename\":\"audit\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("ROLE_EXISTS")); + + // Removing a role detaches it from the users that hold it. + request(client, "POST", MANAGER2 + "/api/users/alice/roles", token, "{\"roles\":[\"ops\",\"audit\"]}", 200); + request(client, "DELETE", MANAGER2 + "/api/roles/audit", token, null, 200); + request(client, "GET", MANAGER2 + "/api/users", null, null, 200); + body = client.getResponseBody(); + Assert.assertFalse(body.contains("\"rolename\":\"audit\"")); + Assert.assertTrue(body.contains("\"roles\":[\"ops\"]")); + Assert.assertFalse(readFile(xmlFile).contains("rolename=\"audit\"")); + + // An unknown role is reported. + request(client, "DELETE", MANAGER2 + "/api/roles/ghost", token, null, 404); + Assert.assertTrue(client.getResponseBody().contains("ROLE_NOT_FOUND")); + + // A role held by the signed-in account cannot be removed. + request(client, "DELETE", MANAGER2 + "/api/roles/manager-gui", token, null, 400); + Assert.assertTrue(client.getResponseBody().contains("SELF_ROLE_REMOVAL")); + + // Removing a user removes it from the database and the file. + request(client, "DELETE", MANAGER2 + "/api/users/alice", token, null, 200); + Assert.assertFalse(readFile(xmlFile).contains("username=\"alice\"")); + + // Existing names are rejected, unknown ones reported. + request(client, "POST", MANAGER2 + "/api/users", token, "{\"username\":\"manager1\",\"password\":\"x\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("USER_EXISTS")); + request(client, "POST", MANAGER2 + "/api/groups", token, "{\"groupname\":\"staff\"}", 200); + request(client, "POST", MANAGER2 + "/api/groups", token, "{\"groupname\":\"staff\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("GROUP_EXISTS")); + request(client, "DELETE", MANAGER2 + "/api/users/ghost", token, null, 404); + Assert.assertTrue(client.getResponseBody().contains("USER_NOT_FOUND")); + request(client, "DELETE", MANAGER2 + "/api/groups/ghost", token, null, 404); + Assert.assertTrue(client.getResponseBody().contains("GROUP_NOT_FOUND")); + + // The signed-in account cannot remove itself. + request(client, "DELETE", MANAGER2 + "/api/users/manager1", token, null, 400); + Assert.assertTrue(client.getResponseBody().contains("SELF_REMOVAL")); + + client.disconnect(); + } + + + @Test + public void testUsersApiReadOnlyRoleDenied() throws Exception { + setup(true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "status1"); + + // The users API is not part of the read-only status endpoints. + request(client, "GET", MANAGER2 + "/api/users", null, null, 403); + request(client, "GET", MANAGER2 + "/api/groups", null, null, 403); + + client.disconnect(); + } + + + // ----------------------------------------------------------------------- + + private void setup(boolean withReadOnlyUser) throws Exception { + setup(withReadOnlyUser, false); + } + + + private void setup(boolean withReadOnlyUser, boolean withNaming) throws Exception { + Tomcat tomcat = getTomcatInstance(); + tomcat.setAddDefaultWebXmlToWebapp(false); + if (withNaming) { + // Enable JNDI naming so that a UserDatabase JNDI resource can be + // registered on the global naming context of the server, like the + // file based one of the default server.xml. + tomcat.enableNaming(); + } + tomcat.addUser("manager1", "secret"); + tomcat.addRole("manager1", "manager-gui"); + if (withReadOnlyUser) { + tomcat.addUser("status1", "secret"); + tomcat.addRole("status1", "manager-status"); + } + + File webapps = new File(getBuildDirectory(), "webapps"); + File appDir = new File(webapps, "manager2"); + File appWar = new File(webapps, "manager2.war"); + File source = appDir.exists() ? appDir : appWar; + Assert.assertTrue("manager2 webapp missing - run 'ant deploy' first", source.exists()); + Context manager2 = tomcat.addWebapp(null, MANAGER2, source.getAbsolutePath()); + addDefaultServlet(manager2); + // The programmatic test instance has no global web.xml, so the MIME + // type mappings that a normal deployment gets from it are missing. + // The default servlet needs them to set the correct content type for + // the static resources. + manager2.addMimeMapping("css", "text/css"); + manager2.addMimeMapping("js", "text/javascript"); + manager2.addMimeMapping("html", "text/html"); + + // The programmatic test instance does not create a HostConfig for + // the default host, so the Deployer MBean that the deploy API uses + // (HostConfig registers itself under "type=Deployer") is missing. + // The test app is deployed from the host app base, like a real + // webapps/ directory, so that undeploy is allowed. + File appBase = new File(TEMP_DIR, "manager2-test-appbase"); + deleteRecursive(appBase); + Assert.assertTrue(appBase.mkdirs()); + createTestWebapp(new File(appBase, "testapp")); + org.apache.catalina.Host host = tomcat.getHost(); + host.setAppBase(appBase.getAbsolutePath()); + host.addLifecycleListener(new HostConfig()); + + tomcat.start(); + } + + + /** + * The programmatic test instance has no global web.xml, so the default servlet that serves static resources in a + * normal deployment has to be added explicitly. Without it, FORM authentication cannot forward to the login page. + */ + private void addDefaultServlet(Context context) { + Tomcat.addServlet(context, "default", new DefaultServlet()); + context.addServletMapping("/", "default"); + } + + + /** + * Register a file based {@link MemoryUserDatabase} as a JNDI resource of the global naming context of the test + * server, like the {@code UserDatabase} resource of the default {@code server.xml}. Requires a setup with + * {@code withNaming = true}. + */ + private MemoryUserDatabase addUserDatabase(String name, File xmlFile, boolean readonly) throws Exception { + Server server = getTomcatInstance().getServer(); + ContextResource resource = new ContextResource(); + resource.setName(name); + resource.setType("org.apache.catalina.UserDatabase"); + resource.setProperty("factory", "org.apache.catalina.users.MemoryUserDatabaseFactory"); + resource.setProperty("pathname", xmlFile.getAbsolutePath()); + if (!readonly) { + resource.setProperty("readonly", "false"); + } + server.getGlobalNamingResources().addResource(resource); + return (MemoryUserDatabase) server.getGlobalNamingContext().lookup(name); + } + + + /** + * Establish a login session for the given user via the FORM login. + */ + private void login(SimpleHttpClient client, String user) throws Exception { + // Establish a session (and the "saved request") via a protected + // resource. + client.setRequest(new String[] { "GET " + MANAGER2 + "/api/info HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + + Assert.assertNotNull("Expected a session to be established", client.getSessionId()); + + // Submit the login form. + String body = "j_username=" + user + "&j_password=secret"; + client.setRequest(new String[] { "POST " + MANAGER2 + "/j_security_check HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Cookie: JSESSIONID=" + client.getSessionId() + CRLF, + "Content-Type: application/x-www-form-urlencoded" + CRLF, + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + CRLF, "Connection: Close" + CRLF, + CRLF, body }); + client.connect(); + client.processRequest(true); + + Assert.assertEquals(303, client.getStatusCode()); + } + + + /** + * Log in and return the CSRF token for the new session. The login response (303) does not carry a token, so a + * read-only endpoint is requested afterwards to issue one. + */ + private String loginAndGetToken(SimpleHttpClient client, String user) throws Exception { + login(client, user); + request(client, "GET", MANAGER2 + "/api/info", null, null, 200); + String token = getCsrfToken(client); + Assert.assertNotNull("Expected an X-CSRF-Token header", token); + return token; + } + + + private void request(SimpleHttpClient client, String method, String path, String token, String body, + int expectedStatus) throws Exception { + StringBuilder request = new StringBuilder(); + request.append(method).append(' ').append(path).append(" HTTP/1.1").append(CRLF); + request.append("Host: localhost:").append(getPort()).append(CRLF); + if (client.getSessionId() != null) { + request.append("Cookie: JSESSIONID=").append(client.getSessionId()).append(CRLF); + } + if (token != null) { + request.append("X-CSRF-Token: ").append(token).append(CRLF); + } + if (body != null) { + request.append("Content-Type: application/json").append(CRLF); + request.append("Content-Length: ").append(body.getBytes(StandardCharsets.UTF_8).length).append(CRLF); + } + request.append("Connection: Close").append(CRLF); + request.append(CRLF); + if (body != null) { + request.append(body); + } + // Note: the request parts must keep their CRLF terminators, so the + // whole request is sent as a single part. + client.setRequest(new String[] { request.toString() }); + client.connect(); + client.processRequest(true); + if (client.getStatusCode() != expectedStatus) { + System.out.println("DBG unexpected: " + method + " " + path + " status=" + client.getStatusCode() + + " body=" + client.getResponseBody().substring(0, Math.min(300, client.getResponseBody().length()))); + } + Assert.assertEquals(expectedStatus, client.getStatusCode()); + } + + + private void requestRaw(SimpleHttpClient client, String method, String path, int expectedStatus) throws Exception { + StringBuilder request = new StringBuilder(); + request.append(method).append(' ').append(path).append(" HTTP/1.1").append(CRLF); + request.append("Host: localhost:").append(getPort()).append(CRLF); + if (client.getSessionId() != null) { + request.append("Cookie: JSESSIONID=").append(client.getSessionId()).append(CRLF); + } + request.append("Connection: Close").append(CRLF); + request.append(CRLF); + client.setRequest(new String[] { request.toString() }); + client.connect(); + client.processRequest(true); + if (client.getStatusCode() != expectedStatus) { + System.out.println("DBG unexpected: " + method + " " + path + " status=" + client.getStatusCode() + + " body=" + client.getResponseBody().substring(0, Math.min(300, client.getResponseBody().length()))); + } + Assert.assertEquals(expectedStatus, client.getStatusCode()); + } + + + private String getCsrfToken(SimpleHttpClient client) { + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("x-csrf-token: ")) { + return header.substring("X-CSRF-Token: ".length()); + } + } + return null; + } + + + private void createTestWebapp(File dir) throws IOException { + deleteRecursive(dir); + File webInf = new File(dir, "WEB-INF"); + Assert.assertTrue(webInf.mkdirs()); + + try (PrintWriter pw = new PrintWriter(new File(webInf, "web.xml"), StandardCharsets.UTF_8)) { + pw.println(""); + pw.println(""); + pw.println(" Manager2 Test App"); + // The default servlet must be defined in web.xml: the test + // instance has no global web.xml and context stop() resets all + // wrappers, re-creating them only from web.xml on start(). + pw.println(" "); + pw.println(" default"); + pw.println(" org.apache.catalina.servlets.DefaultServlet"); + pw.println(" "); + pw.println(" "); + pw.println(" default"); + pw.println(" /"); + pw.println(" "); + // The test instance has no global web.xml, so the JSP servlet + // has to be declared as well. + pw.println(" "); + pw.println(" jsp"); + pw.println(" org.apache.jasper.servlet.JspServlet"); + pw.println(" "); + pw.println(" fork"); + pw.println(" false"); + pw.println(" "); + pw.println(" "); + pw.println(" "); + pw.println(" jsp"); + pw.println(" *.jsp"); + pw.println(" "); + pw.println(" "); + pw.println(" index.html"); + pw.println(" "); + pw.println(""); + } + + try (PrintWriter pw = new PrintWriter(new File(dir, "index.html"), StandardCharsets.UTF_8)) { + pw.println("manager2 test app"); + } + + try (PrintWriter pw = new PrintWriter(new File(dir, "session.jsp"), StandardCharsets.UTF_8)) { + pw.println("<%@ page session=\"true\" %>"); + pw.println("<% request.getSession(true).setAttribute(\"testAttr\", \"testValue\"); %>"); + pw.println("session created"); + } + } + + + private File createTestWar() throws IOException { + File warFile = new File(TEMP_DIR, "manager2-test.war"); + deleteRecursive(warFile); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(warFile))) { + jos.putNextEntry(new JarEntry("index.html")); + jos.write("manager2 war test".getBytes(StandardCharsets.UTF_8)); + jos.closeEntry(); + jos.putNextEntry(new JarEntry("WEB-INF/web.xml")); + jos.write(("" + "default" + + "org.apache.catalina.servlets.DefaultServlet" + + "default" + + "/" + + "index.html" + "") + .getBytes(StandardCharsets.UTF_8)); + jos.closeEntry(); + } + return warFile; + } + + + private static void writeLogFile(File file, String content) throws IOException { + try (PrintWriter pw = new PrintWriter(file, StandardCharsets.UTF_8)) { + pw.print(content); + } + } + + + /** + * Write a {@code tomcat-users.xml} file with the given (already indented) role, group and user entries. + */ + private static void writeUserDatabaseXml(File file, String entries) throws IOException { + try (PrintWriter pw = new PrintWriter(file, StandardCharsets.UTF_8)) { + pw.println(""); + pw.println(""); + pw.print(entries); + pw.println(""); + } + } + + + private static String readFile(File file) throws IOException { + return new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + + private static void deleteRecursive(File file) { + if (file == null || !file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursive(child); + } + } + file.delete(); + } + + + private static class TestClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } +} diff --git a/modules/manager2/webapp/META-INF/context.xml b/modules/manager2/webapp/META-INF/context.xml new file mode 100644 index 000000000000..acc60d8e06a9 --- /dev/null +++ b/modules/manager2/webapp/META-INF/context.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/modules/manager2/webapp/WEB-INF/web.xml b/modules/manager2/webapp/WEB-INF/web.xml new file mode 100644 index 000000000000..6b7b41814f3e --- /dev/null +++ b/modules/manager2/webapp/WEB-INF/web.xml @@ -0,0 +1,383 @@ + + + + + Tomcat Manager2 Application + + A modern, responsive management web application for the Tomcat Web + Server. Experimental module: web application and virtual host + management plus live runtime statistics. + + + UTF-8 + + + + + AppsApi + org.apache.tomcat.manager2.AppsApiServlet + + debug + 2 + + + + + 52428800 + 52428800 + 0 + + + + HostsApi + org.apache.tomcat.manager2.HostsApiServlet + + debug + 2 + + + + StatusApi + org.apache.tomcat.manager2.StatusApiServlet + + 1 + + + LogsApi + org.apache.tomcat.manager2.LogsApiServlet + + + UsersApi + org.apache.tomcat.manager2.UsersApiServlet + + + ConfigApi + org.apache.tomcat.manager2.ConfigApiServlet + + + + Home + org.apache.tomcat.manager2.HomeServlet + + + + Login + org.apache.tomcat.manager2.LoginServlet + + + + Logout + org.apache.tomcat.manager2.LogoutServlet + + + + Error + org.apache.tomcat.manager2.ErrorServlet + + + + + + AppsApi + /api/apps/* + + + AppsApi + /api/ssl/* + + + AppsApi + /api/leaks + + + AppsApi + /api/resources + + + AppsApi + /api/diagnostics/* + + + HostsApi + /api/hosts/* + + + StatusApi + /api/info + + + StatusApi + /api/csrf + + + StatusApi + /api/status + + + StatusApi + /api/status/* + + + LogsApi + /api/logs + + + LogsApi + /api/logs/file + + + LogsApi + /api/access-log + + + LogsApi + /api/access-log/file + + + UsersApi + /api/users + + + UsersApi + /api/users/* + + + UsersApi + /api/groups + + + UsersApi + /api/groups/* + + + UsersApi + /api/roles + + + UsersApi + /api/roles/* + + + ConfigApi + /api/config/* + + + + Home + + + + Home + /apps + + + Home + /apps/* + + + Home + /hosts + + + Home + /configuration + + + Home + /monitoring + + + Home + /diagnostics + + + Home + /logs + + + Home + /access-log + + + Home + /users + + + Login + /login + + + Logout + /logout + + + Error + /error + + + + + + + CSRF + org.apache.tomcat.manager2.CsrfFilter + + + CSRF + /api/* + + + + + HTTP header security filter + org.apache.catalina.filters.HttpHeaderSecurityFilter + + hstsEnabled + true + + + antiClickJackingOption + DENY + + + + HTTP header security filter + /* + + + + + Headers + org.apache.tomcat.manager2.HeadersFilter + + + Headers + /* + + + + + + + + + + Status API (read-only) + /api/status + /api/status/* + /api/info + /api/csrf + + + manager-gui + manager-status + + + + + + + Manager2 API + /api/* + + + manager-gui + + + + + + + + + FORM + Tomcat Manager2 Application + + + /login + /login?error=1 + + + + + + + + The role required to access the Manager2 web application (full + management rights). + + manager-gui + + + + The role required to read the runtime statistics of the server. + + manager-status + + + + + + 403 + /error + + + 404 + /error + + + diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css new file mode 100644 index 000000000000..a7e50bc114cc --- /dev/null +++ b/modules/manager2/webapp/css/manager2.css @@ -0,0 +1,822 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You 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. +*/ + +/* ============================ Design tokens ============================ */ + +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + + --radius-s: 6px; + --radius: 10px; + --radius-l: 16px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + + --topbar-h: 56px; + --sidenav-w: 220px; + --bottomnav-h: 56px; + + --shadow-1: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.10); + --shadow-2: 0 8px 24px rgba(15, 23, 42, 0.14); + + --transition: 140ms ease; + + color-scheme: light dark; +} + +:root, +:root[data-theme="light"] { + --bg: #f4f6fa; + --bg-card: #ffffff; + --bg-inset: #eef1f6; + --bg-hover: #eef2f8; + --bg-topbar: #ffffff; + --bg-sidenav: #ffffff; + + --text: #1a2233; + --text-soft: #51607a; + --text-faint: #8592a8; + + --border: #dde3ec; + --border-strong: #c6cfdd; + + --accent: #e7600c; + --accent-soft: #fdeadd; + --accent-text: #b34600; + + --ok: #1a7f4b; + --ok-soft: #e2f5ea; + --warn: #9a6700; + --warn-soft: #fdf3d7; + --danger: #c02735; + --danger-soft: #fde8ea; + --info: #2456a6; + --info-soft: #e7eefb; +} + +:root[data-theme="dark"] { + --bg: #12151c; + --bg-card: #1a1f2a; + --bg-inset: #212836; + --bg-hover: #242c3c; + --bg-topbar: #171b24; + --bg-sidenav: #171b24; + + --text: #e8ecf4; + --text-soft: #aab4c8; + --text-faint: #7d8aa3; + + --border: #2b3345; + --border-strong: #3a455d; + + --accent: #ff7d33; + --accent-soft: #3a2415; + --accent-text: #ffa26b; + + --ok: #4cc98a; + --ok-soft: #16321f; + --warn: #e5b93c; + --warn-soft: #38300f; + --danger: #f06a78; + --danger-soft: #3d1a20; + --info: #6ea8ff; + --info-soft: #16233d; + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +/* ============================ Base ===================================== */ + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: var(--font); + font-size: 14.5px; + line-height: 1.45; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4 { margin: 0; font-weight: 650; } +h1 { font-size: 22px; } +h2 { font-size: 17px; } +h3 { font-size: 15px; } + +a { color: var(--accent-text); text-decoration: none; } +a:hover { text-decoration: underline; } + +code, pre, .mono { font-family: var(--mono); font-size: 12.5px; } + +button { font: inherit; color: inherit; cursor: pointer; } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-s); +} + +/* The HTML "hidden" attribute must always win over the display rules below + (e.g. .shell { display: grid }): the SPA shell stays invisible until the + application has booted and confirmed that the user is signed in. */ +[hidden] { display: none !important; } + +/* ============================ Shell layout ============================= */ + +.shell { display: grid; grid-template-rows: var(--topbar-h) 1fr; height: 100dvh; overflow: hidden; } + +.topbar { + display: flex; + align-items: center; + gap: var(--space-4); + padding: 0 var(--space-5); + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + z-index: 20; +} + +.topbar-brand { display: flex; align-items: center; gap: 10px; min-width: 0; } +.topbar .logo { display: block; height: 26px; width: auto; flex: none; } +.brand-name { font-weight: 700; font-size: 16px; white-space: nowrap; } +.brand-version { + font-size: 11.5px; color: var(--text-faint); + border: 1px solid var(--border); border-radius: 99px; + padding: 2px 8px; white-space: nowrap; +} + +.topbar-status { + margin-left: auto; + display: flex; align-items: center; gap: 8px; + font-size: 12.5px; color: var(--text-soft); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.topbar-actions { display: flex; align-items: center; gap: var(--space-2); } + +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 34px; height: 34px; + background: transparent; border: 1px solid transparent; border-radius: var(--radius-s); + color: var(--text-soft); +} +.icon-btn:hover { background: var(--bg-hover); border-color: var(--border); } +.icon-btn svg { fill: currentColor; } + +.shell-main { + display: grid; + grid-template-columns: var(--sidenav-w) 1fr; + min-height: 0; +} + +.shell { grid-template-rows: var(--topbar-h) 1fr; } + +.sidenav { + display: flex; flex-direction: column; gap: 2px; + padding: var(--space-4) var(--space-3); + background: var(--bg-sidenav); + border-right: 1px solid var(--border); + overflow-y: auto; +} + +.nav-item { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; + background: transparent; + border: none; border-radius: var(--radius-s); + color: var(--text-soft); + font-size: 14px; font-weight: 500; + text-align: left; + transition: background var(--transition), color var(--transition); +} +.nav-item svg { fill: currentColor; flex: none; } +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--accent-text); font-weight: 600; } + +.content { + min-width: 0; + overflow-y: auto; + padding: var(--space-5) var(--space-6) 60px; + scroll-behavior: smooth; +} + +.page-head { + display: flex; align-items: baseline; gap: var(--space-3); + margin-bottom: var(--space-5); + flex-wrap: wrap; +} +.page-head p { margin: 0; color: var(--text-soft); font-size: 13px; } + +/* ============================ Cards / grid ============================= */ + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-1); + padding: var(--space-4) var(--space-5); + margin-bottom: var(--space-4); +} +.card h2, .card h3 { margin-bottom: var(--space-3); } +.card-title-row { + display: flex; align-items: center; justify-content: space-between; + gap: var(--space-3); margin-bottom: var(--space-3); flex-wrap: wrap; +} +.card-title-row h2, .card-title-row h3 { margin: 0; } + +.grid { display: grid; gap: var(--space-4); margin-bottom: var(--space-4); } +.grid.kpis { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +.grid.charts { grid-template-columns: repeat(12, 1fr); } +.grid.charts .col-6 { grid-column: span 6; } +.grid.charts .col-4 { grid-column: span 4; } +.grid.charts .col-12 { grid-column: span 12; } + +.kpi { display: flex; flex-direction: column; gap: 6px; } +.kpi .kpi-label { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-faint); } +.kpi .kpi-value { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; } +.kpi .kpi-sub { font-size: 12px; color: var(--text-soft); } + +/* ============================ Tables =================================== */ + +.table-wrap { overflow-x: auto; } + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} +table.data th { + text-align: left; + font-size: 11.5px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; + color: var(--text-faint); + padding: 8px 10px; + border-bottom: 1px solid var(--border-strong); + white-space: nowrap; +} +table.data th.sortable { cursor: pointer; user-select: none; } +table.data th.sortable:hover { color: var(--text); } +table.data td { + padding: 9px 10px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +table.data tbody tr:hover { background: var(--bg-hover); } +table.data .num { text-align: right; font-variant-numeric: tabular-nums; } +table.data .muted { color: var(--text-faint); } + +.row-actions { display: flex; gap: 6px; flex-wrap: wrap; } + +/* ============================ Log pages ================================= */ + +.muted { color: var(--text-faint); } + +.log-controls { + display: flex; gap: var(--space-4); align-items: flex-end; + flex-wrap: wrap; +} +.log-field { width: 220px; margin-bottom: 0; } +.log-field-search { width: 260px; } +.log-field-btn { width: auto; } +.log-filters { display: flex; gap: var(--space-4); flex-wrap: wrap; flex: 1 1 auto; } +.log-filters .field { width: 160px; margin-bottom: 0; } +.log-filters .log-field-search { width: 220px; } +.log-status { font-size: 12.5px; } +.log-table table.data td { overflow-wrap: anywhere; max-width: 640px; } +.log-table table.data code { word-break: break-all; } + +/* ============================ Users page =============================== */ + +.users-page .card { margin-bottom: var(--space-4); } + +.banner { + border: 1px solid; + border-radius: var(--radius, 8px); + padding: 10px 14px; + margin-bottom: var(--space-4); + font-size: 13.5px; +} +.banner p { margin: 0; } +.banner.warn { color: var(--warn); background: var(--warn-soft); border-color: currentColor; } + +.chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.chips .inherited { font-size: 12px; color: var(--text-faint); } +.user-cell .muted { font-weight: 400; } + +.users-page .table-wrap table.data td { vertical-align: middle; } + +/* ============================ Badges =================================== */ + +.badge { + display: inline-flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 600; + padding: 3px 10px; + border-radius: 99px; + border: 1px solid transparent; + white-space: nowrap; +} +.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.badge.ok { color: var(--ok); background: var(--ok-soft); border-color: currentColor; } +.badge.stop { color: var(--text-faint); background: var(--bg-inset); border-color: var(--border-strong); } +.badge.warn { color: var(--warn); background: var(--warn-soft); border-color: currentColor; } +.badge.danger { color: var(--danger); background: var(--danger-soft); border-color: currentColor; } +.badge.info { color: var(--info); background: var(--info-soft); border-color: currentColor; } +.badge.plain { border: 1px solid var(--border); color: var(--text-soft); background: var(--bg-inset); } +.badge.plain::before { display: none; } + +/* ============================ Buttons ================================== */ + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 8px 14px; + border-radius: var(--radius-s); + border: 1px solid var(--border-strong); + background: var(--bg-card); + color: var(--text); + font-size: 13.5px; font-weight: 550; + transition: background var(--transition), border-color var(--transition), opacity var(--transition); + white-space: nowrap; +} +.btn:hover { background: var(--bg-hover); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn svg { fill: currentColor; } + +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-text); border-color: var(--accent-text); } +:root[data-theme="light"] .btn-primary { color: #fff; } + +.btn-danger { color: var(--danger); border-color: var(--danger); background: transparent; } +.btn-danger:hover { background: var(--danger-soft); } + +.btn-ghost { border-color: transparent; background: transparent; color: var(--text-soft); } +.btn-ghost:hover { background: var(--bg-hover); color: var(--text); } + +.btn-sm { padding: 4px 10px; font-size: 12.5px; } +.btn-block { width: 100%; } + +/* ============================ Forms ==================================== */ + +.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: var(--space-4); } +.field label { font-size: 12.5px; font-weight: 600; color: var(--text-soft); } +.field .hint { font-size: 11.5px; color: var(--text-faint); } + +input[type="text"], input[type="password"], input[type="number"], select, textarea { + font: inherit; + padding: 8px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-s); + background: var(--bg-card); + color: var(--text); + width: 100%; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); } +textarea { resize: vertical; min-height: 90px; font-family: var(--mono); font-size: 12.5px; } + +.check { + display: flex; align-items: center; gap: 8px; + font-size: 13.5px; color: var(--text-soft); + margin-bottom: 8px; + cursor: pointer; +} +.check input { width: 15px; height: 15px; accent-color: var(--accent); } + +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--space-4); } +.form-grid .span-2 { grid-column: span 2; } + +.dropzone { + border: 2px dashed var(--border-strong); + border-radius: var(--radius); + padding: var(--space-6) var(--space-4); + text-align: center; + color: var(--text-soft); + transition: border-color var(--transition), background var(--transition); + cursor: pointer; +} +.dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--accent-soft); } +.dropzone .dz-title { font-weight: 600; color: var(--text); margin-bottom: 4px; } + +.progress { + height: 8px; border-radius: 99px; overflow: hidden; + background: var(--bg-inset); margin-top: var(--space-3); +} +.progress > div { height: 100%; width: 0%; background: var(--accent); border-radius: 99px; transition: width 200ms ease; } + +/* ============================ Tabs ===================================== */ + +.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: var(--space-4); } +.tab { + padding: 9px 14px; + background: transparent; border: none; border-bottom: 2px solid transparent; + color: var(--text-soft); + font-size: 13.5px; font-weight: 550; + margin-bottom: -1px; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent-text); border-bottom-color: var(--accent); } + +/* ============================ Modal ==================================== */ + +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(10, 14, 22, 0.5); + display: flex; align-items: flex-start; justify-content: center; + padding: 8vh var(--space-4) var(--space-4); + z-index: 100; +} +.modal { + width: 100%; max-width: 560px; + max-height: 84vh; + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-2); + padding: var(--space-5); +} +.modal h2 { margin-bottom: var(--space-4); } +.modal-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); } +.modal.wide { max-width: 860px; } + +/* ============================ Drawer =================================== */ + +.drawer-backdrop { + position: fixed; inset: 0; + background: rgba(10, 14, 22, 0.4); + z-index: 90; +} +.drawer { + position: fixed; top: 0; right: 0; bottom: 0; + width: min(480px, 100vw); + background: var(--bg-card); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-2); + z-index: 95; + display: flex; flex-direction: column; +} +.drawer-head { + display: flex; align-items: center; justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); +} +.drawer-body { padding: var(--space-5); overflow-y: auto; } + +/* ============================ Toasts =================================== */ + +.toast-region { + position: fixed; bottom: var(--space-5); left: 50%; transform: translateX(-50%); + display: flex; flex-direction: column; gap: var(--space-2); + z-index: 200; + width: min(560px, calc(100vw - 32px)); +} +.toast { + display: flex; align-items: flex-start; gap: 10px; + padding: 11px 14px; + border-radius: var(--radius); + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 4px solid var(--info); + box-shadow: var(--shadow-2); + font-size: 13.5px; + animation: toast-in 180ms ease; +} +.toast.ok { border-left-color: var(--ok); } +.toast.warn { border-left-color: var(--warn); } +.toast.error { border-left-color: var(--danger); } +.toast .toast-msg { flex: 1; overflow-wrap: anywhere; } +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ============================ Charts =================================== */ + +.chart-card canvas { width: 100%; height: 190px; display: block; } +.chart-legend { display: flex; gap: var(--space-4); flex-wrap: wrap; margin-top: var(--space-2); font-size: 12px; color: var(--text-soft); } +.chart-legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; } + +.gauge-wrap { display: flex; align-items: center; gap: var(--space-4); } +.gauge-wrap canvas { width: 120px; height: 76px; flex: none; } +.gauge-value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; } +.gauge-sub { font-size: 12px; color: var(--text-faint); } + +.live-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--ok); margin-right: 7px; + animation: pulse 2s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* ============================ Detail / misc ============================ */ + +.kv { display: grid; grid-template-columns: 200px 1fr; gap: 6px var(--space-4); font-size: 13.5px; } +.kv dt { color: var(--text-soft); } +.kv dd { margin: 0; overflow-wrap: anywhere; } + +pre.block { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius-s); + padding: var(--space-3); + overflow-x: auto; + max-height: 420px; + white-space: pre; +} + +.empty { + text-align: center; color: var(--text-faint); + padding: var(--space-6) var(--space-4); +} + +.spinner { + width: 26px; height: 26px; margin: var(--space-5) auto; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.breadcrumb { font-size: 13px; color: var(--text-faint); margin-bottom: var(--space-3); } +.breadcrumb a { color: var(--text-soft); } + +/* ============================ Configuration page ======================= */ + +.config-split { + display: grid; + grid-template-columns: 400px minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} +.config-tree-card, .config-detail-card { margin-bottom: 0; } +.config-detail-card { min-height: 240px; } +.config-detail-card h4 { margin: var(--space-4) 0 var(--space-2); font-size: 13px; } + +.config-tree { + max-height: calc(100dvh - var(--topbar-h) - 190px); + overflow-y: auto; + padding-right: 2px; +} +.config-node { + display: flex; align-items: center; gap: 2px; + padding: 3px 6px; + border-radius: var(--radius-s); +} +.config-node:hover { background: var(--bg-hover); } +.config-node-toggle { + flex: none; + width: 18px; height: 18px; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; + background: transparent; border: none; border-radius: 4px; + font-size: 9px; line-height: 1; + color: var(--text-faint); +} +.config-node-toggle:hover { background: var(--bg-inset); color: var(--text); } +.config-node-label { + display: flex; align-items: center; gap: 7px; + flex: 1; min-width: 0; + padding: 3px 6px; + font-size: 13.5px; +} +.config-node-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.config-type { + flex: none; + font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; + padding: 1px 8px; + border-radius: 99px; + border: 1px solid var(--border); + background: var(--bg-inset); + color: var(--text-soft); +} +.config-type.server, .config-type.engine { background: var(--accent-soft); border-color: transparent; color: var(--accent-text); } +.config-type.service, .config-type.connector { background: var(--info-soft); border-color: transparent; color: var(--info); } +.config-type.context { background: var(--ok-soft); border-color: transparent; color: var(--ok); } +.config-type.host { background: var(--warn-soft); border-color: transparent; color: var(--warn); } +.config-type.realm { background: var(--danger-soft); border-color: transparent; color: var(--danger); } +.config-type.manager { background: var(--info-soft); border-color: transparent; color: var(--info); } +.config-type.sessionIdGenerator { background: var(--danger-soft); border-color: transparent; color: var(--danger); } +.config-type.resources { background: var(--ok-soft); border-color: transparent; color: var(--ok); } +.config-type.loader { background: var(--warn-soft); border-color: transparent; color: var(--warn); } +.config-type.cookieProcessor { background: var(--accent-soft); border-color: transparent; color: var(--accent-text); } +.config-type.namingResources { background: var(--accent-soft); border-color: transparent; color: var(--accent-text); } +.config-type.resource { background: var(--info-soft); border-color: transparent; color: var(--info); } +.config-type.resourceLink { background: var(--ok-soft); border-color: transparent; color: var(--ok); } +.config-type.resourceEnvRef { background: var(--warn-soft); border-color: transparent; color: var(--warn); } +.config-type.environment { background: var(--accent-soft); border-color: transparent; color: var(--accent-text); } +.config-type.ejb, .config-type.localEjb { background: var(--danger-soft); border-color: transparent; color: var(--danger); } +.config-type.serviceRef { background: var(--warn-soft); border-color: transparent; color: var(--warn); } + +.config-detail-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; } +.config-detail-title h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.config-class { margin: 0 0 var(--space-4); color: var(--text-faint); } +.config-class code { overflow-wrap: anywhere; } + +.config-file-list { margin: 4px 0 var(--space-4); padding-left: 20px; } +.config-file-list li { margin: 2px 0; } +.config-file-list code { background: var(--bg-inset); padding: 1px 5px; border-radius: 4px; } +.config-store-warning { + margin-top: var(--space-4); + padding: 10px 12px; + border-radius: var(--radius-s); + background: var(--danger-soft); + color: var(--danger); + font-size: 13px; +} + +.config-section-head { + display: flex; align-items: center; justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-2); +} +.config-section-head h4 { margin: 0; } + +.config-param-form { + display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; + margin-bottom: var(--space-2); + padding: 10px 12px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-s); + background: var(--bg-inset); +} +.config-param-form .config-input { flex: 1; min-width: 140px; } + +.config-props { display: flex; flex-direction: column; gap: var(--space-2); } +.config-prop { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: var(--space-3); + align-items: center; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-s); +} +.config-prop.readonly { border-color: transparent; padding-left: 2px; padding-right: 2px; } +.config-prop-name { + display: flex; flex-direction: column; gap: 2px; + min-width: 0; + margin: 0; + font-size: 13px; font-weight: 600; +} +.config-prop-desc { + font-size: 11.5px; font-weight: 400; color: var(--text-faint); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.config-param-hint { + font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; + color: var(--text-faint); +} +.config-prop-value { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; } +.config-prop-edit { display: flex; align-items: center; gap: var(--space-2); min-width: 0; } +.config-prop-edit .config-input { flex: 1; min-width: 0; } +.config-check { width: 16px; height: 16px; accent-color: var(--accent); flex: none; } + +.config-children { display: flex; flex-wrap: wrap; gap: 6px; } +.config-child-chip { + display: inline-flex; align-items: center; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--bg-inset); + color: var(--text-soft); + font-size: 12.5px; font-weight: 500; + transition: background var(--transition), color var(--transition), border-color var(--transition); +} +.config-child-chip:hover { background: var(--bg-hover); border-color: var(--border-strong); color: var(--text); } + +.config-xml { + margin: var(--space-3) 0 0; + max-height: 340px; + overflow: auto; + white-space: pre; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-s); + background: var(--bg-inset); +} + +@media (max-width: 1100px) { + .config-split { grid-template-columns: 1fr; } + .config-tree { max-height: 320px; } + .config-prop { grid-template-columns: 1fr; gap: 6px; } +} + +/* ============================ Login page =============================== */ + +.login-body { + display: flex; align-items: center; justify-content: center; + min-height: 100dvh; + padding: var(--space-4); + background: + radial-gradient(1000px 520px at 50% -12%, var(--accent-soft), transparent 70%), + var(--bg); +} +.login-card { + width: 100%; max-width: 400px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-2); + padding: var(--space-6); +} +/* The 403/404 pages reuse the card and are centered. */ +.login-card:has(.error-code) { text-align: center; } + +.login-brand { text-align: center; margin-bottom: var(--space-5); } +.login-logo { + display: inline-flex; align-items: center; justify-content: center; + width: 52px; height: 52px; + border-radius: 14px; + background: var(--accent-soft); + margin-bottom: var(--space-3); +} +.login-logo img { display: block; height: 30px; width: auto; } +.login-brand h1 { font-size: 19px; letter-spacing: -0.01em; } +.login-sub { color: var(--text-soft); font-size: 13px; margin: 4px 0 0; } + +.login-error { + color: var(--danger); background: var(--danger-soft); + border: 1px solid currentColor; + border-radius: var(--radius-s); + padding: 9px 12px; font-size: 13px; + margin-bottom: var(--space-4); +} +.login-error strong { font-weight: 650; } + +.login-form .field { margin-bottom: var(--space-4); } +.login-form .btn { margin-top: var(--space-2); padding: 9px 14px; } + +.login-foot { + margin: var(--space-5) 0 0; + padding-top: var(--space-4); + border-top: 1px solid var(--border); + font-size: 11.5px; line-height: 1.55; + color: var(--text-faint); + text-align: center; +} +.login-foot code { font-size: 10.5px; } + +.error-code { font-size: 56px; font-weight: 800; color: var(--accent); margin: 0; line-height: 1; } +.login-card:has(.error-code) h1 { margin: var(--space-3) 0 var(--space-2); } +.login-card:has(.error-code) p { color: var(--text-soft); font-size: 13.5px; margin: 0 0 var(--space-5); } +.login-card:has(.error-code) .btn { display: inline-flex; } + +/* ============================ Responsive =============================== */ + +@media (max-width: 1024px) { + .grid.charts .col-6 { grid-column: span 12; } + .grid.charts .col-4 { grid-column: span 12; } +} + +@media (max-width: 768px) { + .shell-main { grid-template-columns: 1fr; } + .sidenav { + position: fixed; bottom: 0; left: 0; right: 0; + top: auto; + flex-direction: row; + justify-content: space-around; + padding: 4px; + border-right: none; + border-top: 1px solid var(--border); + height: var(--bottomnav-h); + z-index: 30; + } + .nav-item { flex-direction: column; gap: 3px; font-size: 10.5px; padding: 6px 10px; } + .content { padding: var(--space-4) var(--space-4) calc(var(--bottomnav-h) + var(--space-5)); } + .topbar-status { display: none; } + .brand-version { display: none; } + .form-grid { grid-template-columns: 1fr; } + .form-grid .span-2 { grid-column: span 1; } + .kv { grid-template-columns: 1fr; gap: 2px; } + .kv dt { margin-top: 8px; } + h1 { font-size: 19px; } +} diff --git a/modules/manager2/webapp/error-403.html b/modules/manager2/webapp/error-403.html new file mode 100644 index 000000000000..43a9f6521daf --- /dev/null +++ b/modules/manager2/webapp/error-403.html @@ -0,0 +1,35 @@ + + + + + + + 403 · Tomcat Manager + + + + +
+

403

+

Access denied

+

Your account does not have permission to perform this action. If you + believe this is an error, contact your server administrator.

+ Back to dashboard +
+ + diff --git a/modules/manager2/webapp/error-404.html b/modules/manager2/webapp/error-404.html new file mode 100644 index 000000000000..aa745a679c1b --- /dev/null +++ b/modules/manager2/webapp/error-404.html @@ -0,0 +1,34 @@ + + + + + + + 404 · Tomcat Manager + + + + +
+

404

+

Not found

+

The page or resource you requested does not exist.

+ Back to dashboard +
+ + diff --git a/modules/manager2/webapp/images/favicon.ico b/modules/manager2/webapp/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6c5bd2c63b38b326cf01f7f9fc61f7b21b5bc5ae GIT binary patch literal 21630 zcmeHP2V7KF);}l+mZdpS12dp7GgxTSG#Vo@ z(O42=Ta6`lqX=qx-EY&s#3b&TWN?4G%lE(U%{<2eO*5PP@_8NJyXD+-&pY?LbI-l! zJc%r*Hw_#(kXRaLLDYb|p-9WDh>oL9DEA~9V@))2BoF_252AH_Sef-E+HOO%;vtiA zKcW%#M9$7U{IXs|=SH!z0lq(x(|D8eFrqhRfDSaX9wYH*L6*1@z06(t3y;lMp(etQ z)N1~QE3)+R%P-^38alZjb#}5G{_u}gv5O%MKmqB7@ou9+Nv~DgRCQG8wVE3Pv}&zZ zp}H}kL!nV<0VmhuAL>lIZRIqvAL)oQuBrjsw5;x~@VsQ4v+ck&6$ zNAkpc3CWPh*!1nU-_rH#*XiSrKc@HIdylSOyGE_8t#sy<%XH@4c{+Oh1RXqljCSrl zNKfy2k)D3~X=>W`Jk_@Bp^9e?lJ2QpRQ=?$RQANPw6bvv)irOXinNYqaGF5@*wko z1OHz*z}|gt^K|FKdwZLg2lqt{lsi$+{*Mp#_U`9MZ3Z)z!sWG~~}cSs4pSACKwNMm+N9ut8k82U%G5 zb{zW1=!b2&{=KX`AEJRnMh+eRhy~!G_o2JSIND7ZGm$SdV%&QZS-IUVvpOUWp3fS} zX3zUebS&$8r=E4-2JysNcpnm`=SL=fupbEwFdkmLc0Dw@(Cf=Dzr1zpmQeiUlTV=I zp=wLl*75M$cJE7DwH^Tf`Okl#){$=9xPdNLuDuHF4`mG9izVY<|N0jX4=}6(B{iG! z3rnD4TXycnf&rcGQ+Ev77)l#!3qPDdKUN|rW!J{@^VjX2({LJ!8#;SN{RL=yEDBho z%-}!x;DdMGc}G>dVcxp^QyVYg=T&_KO9ED$s04Zd0CxTT?|(-n@?a*`z4VG& ztfE-(uy0rL?s!1u`fJb1p>kG}qLduAlY z^2DFf?o)GW2o*^8+wSAB5*h41BlC=Q;EyibXjMj7X85U1%L0XYSAYyw)@v1H-3c2QgR*j`UXCspPSdI9%B+7U2Cz|RZkM$- zKxlKjtVYL9_3bi70U5W;tcb+$o2DNu8|V4=a%bzwNc?%?&w_ZnU2TbiF`3!z(Au2c z4mC)xzy3OBx1Tz7ijEySM*H{gr=2@@QcKIzw0ZMpTEBihRn^o``RZ!YRaR2Dwvx)q zwN$#Qf@J4DrNk?@C=GUce0v8aUjB~KE?lSdv!7GOsgEh?+*cHR`4+{teov9D9Tag% zPjP3jQ^tu8Dd)(WlzZqZ$zE)woaZl4?(Q>`b>goiJ^Thq_g*6DbEhchnIn|ba)`3G z9VY2h2PkX9PLeimr_9=|R8+p2N=jE!`no45y|RHaGOT zek}e*HC`d7=F2psu8s1PrIcNurc^~4rOHc5QoNqx3M(mg#Yze#TuAoZ0>aX7?Mk3O+p0rNw9^&rsK-y@#s!X8P==zg41 zv>KS=;qNbwWd*cbB)HjPlajB@VO=fz#zwgZ1O)qJbH+J)KC1;DVSSb>m5DZe2S}nk z`~!V`gS{5;M03yI+s8dCV&S3;4|j2JV1U1WfH)!xyA_tWcIuxc4hj-S1o{U1BL%pp zPD?OQ%D4B8n;jS+juNw0|A0BkqlY_8=O3DDx9mp`Mfiy4`TGa^2M3Cm*$p2weE3X` z0Ir=eu#ZA4_6T(Mb9YM}JL1uCV;zPpwZapyb_>qtu?$ss`ee`Z%3CpE_{3!A5#!UP z*b1@so;Yq}sduJwQHVJ8(P5KPos(T0MlFHa#M*lpZ6=F8F>}2ZI1G1m96Elo!-yFe zmK%TI?R`wo=vj{poxEU@bF!1eBplsw?N$T9>K@TKQasZ`(}y|OKW^{fIBD3>sfKp4 z8Q7=yVx=-42%{YwoF+`NALQ`Zc!yA9J2rY&mi=&QNEG8Re9#2Zv~j~99yushXlE^a z3UzRCoj9gw+5~48hwj>^xF{2?d-v&M6Rn&+rJMc(6mo;LC@uVRH|@O_8;wbeXdGK| z&u1LFoBOZd{sZrKH#GfMtHxopk@)k(p9S&zUNr>cH%O-T-u=z<4|9KUnqvE7;9uX+ zP*-1HQ&)$xW7DVmsd;Q%pMCaO7-Ufun-`TfFbj!G(Q%1hwMQjo8?e#+sW^Ff?5p?f z+m|TKpTBySd*hkFwR>Rd?cBYa$B%7uR9TZ}-HDc#7Bl?VRBzd`C5lUJZEa?)D8l^S zvu97*isF#+ryk#M1=ePca^-~!7x+^HVSZtC2dvu6S7wXR3C&n&mZF8_$D|w7yuH9 zM1cS2KmYmeyYJ$OGV&CW^3^yq#|g{CjaOk5gS{|I(MteEM!=6e=!srYfpZyH&Dc~h zTa`=c>FJo4mTk{qR{=wM!HUX&b$dZA3~t!t_&GMWhiF=sCk)W@4zr#80%E&l^&=2jn(q%BWd-Ly0#r@g)X$Kc<# zZ5wt7*suU67z>&sR#eVzJY$L(_<G@R$@Hn1z$FaG>MEF-93|p3;xARnVt6p~KTIPwr z%J!T~yz~!j6|pP2cI}!VMW*;6-Z-bgiIqSAwyg5vQfzVpSMQRjo3InY`5iHxNCnaw-n@=G$Rx8M=5o5kj?>p3H^eeuN?Yu2n0(3)nT zxOVT}EmIUntLuZypN?IwPHfkM=Q-OB-NUJYLW zcZZJxUJm#-G^|2K-0%v9gdqTbv}%WYK@+wm%%fSi~%0VRWjCr z!=r+GgvX<IrORNvl`sRfgy41i}t$I7`sUS`VL(!bZd3nWLpst=0gU zTBA}}>kaiC9KmWWX}|_}e>4g;2hZ1op>l1_77o z_3%+)20*P=4=#fdD$ZZT;RXrJ=hF!vlUxNC5yK-Sc&1bx)_S=KJlCd?5g}rWtj*xz zMbm?+Y8$h91^>2nkbxfK05VP#z!CnrjMa=^Tr%)Ms5i;nLFezGp6Qgkr`skI5A&e> z-{Qla>x>OItQrT2LSm=VNRT1={`>E_csm&T1jCqKd+jyOZwGsijvhTqF!~54AN+PL zv~lA`YHV)ie0SJ?(5h9d2qrASD(C!k$!*_J`YT^k#EVW$NT7HyUrl~?p?}8efGg?1Y4Z4cb+D%^)Hj`nU^SM>wc1M zKLLLoV)8ccC26z4Usu;c(&|m{)or3QT?3_7Hc=|>De%`Nm#yP`b@OWeLO6>cpPD0t zy(0y!-brC)PjLRZ*$t;T-`teOi!{Cdtid;T)!>_JqX~_d>9OW^dIYh0!|K~;PVI3@ zD$!AVQ3Zu7YY7`3k`$~X$%@q^$tfdd6tj&+e10)yWMuGuxLhUta0L{WsicKzITV_b zM@!-pC^9*VBIA=OBqf)E6QvXz7f)fa@XN(5gKsX8{1?WPIBF@0agU9QqlltLOp?B?t16&9(} z^^OVfO}Qt$Mepncw|R3VF-iR`Fkk6z0r9stBo+~Qc}1pv6pQ}qnAtw_Jac<9Zyexz z$-P{So;&K932D;u%MByWYCyhZwy%$mcM6<K7*qn?1)@EcTftD>kldoV@kOpB-4x-@+oD zNj7M)ulVb4X^7If4I#17->^ugpTNsg;lOl2DfxQ4PPE{Hl zmj?QA>yzv4FN^UA3Gv7cpY7=__Voc~mc+j9;Rz-hlcR@q$XX`xMGA=V@Dq!9SO)Hy zP_VT8Hn;ib1w2Kmycw8pc(|_s?d9WHxL}ZIWWjCz8u9l^k4*9Q@$&I;jrS4YeFAeQ zi)?L0!}1N>G(Im;x;6F79Vd46^mH=;FLWAWizFJ%Jg8hU;O`rWlFf2?+8nX3mp6EV zN1o@MGC^c#XKQOWNaU~-E>)Hc@Oj)L^@z&#^mUhb`+B|c`X?jy+^1lCNq2YZ}Hi$sH_GnN?Ww=&Yts*rm0UYMhDva_`xKHX{f z=;6U*9%3v?Kfj{Yj0CMoS^eme+Tr|RYdXkf~)1t*rkJ?UD-lm@{d4`fzL|)N~ zD8}nvrOx)YB9X(4A-GH*^Cvrz3ws9)_{plMKi(<}PNehL`_+5uP|;v#kwXwB%+}s% z#%Pfv6H_BRSw^NuEnHZt(`g2BuVX(aNX|4D=SjAMnMByxiN>-wPk;yV9z88A`}bj5 z0BNA3$aaY6;fZ#mm)P0b*@|pMqasjk0?&a7mxxg!`w5TQ*$$bZ8z!>*(_@2w0Dj;o zyL_Eiou5*!%T3BJ(pgP<#VSXtZjwz0PCtJ6i_`Mh^RgFnHgI*w6% zL!_=e)Rdp*;AFW{7iH92tZ@Jo8E$udUWIMB z@<}dJgeZYugA_#SoH=u5$&w{Fn?e8(!Y}Yc5J`k$4U0tQ62fJ|G75**vzVBGsQ9nG z`s&`4;OR`$y@7?+n>TMF-XbJ4EI1((hk2u$+i`3s$z3VQD3mOYN5};`&Eyi0gJ4jL zZ?!du-K505O;U?rlzK@FlIHN1Ya=V04+Sd0AVPIp&8TMKC|(x zUvLDx&Nxhk1Do%{(}O%{m?I8CzzrgDN=r-45DG3?J^y<-r8Hoc148Z3TLTlFy zf{kGF_|6ZI$ke{IM@KhZ1u=+5Li|c#Ow!)Hd(FBC^q?LA7NN;Gh}4-i*B>9En?a%o zZaX-zfXbmmhk}04hCs2dV5q^zZCkf)T^t)XZ_(1Qm75UNG`9H~LZdKE2;4yo7N!hB z(zUNCJ!oCNe0g};CY-d+6$hBcIU$IF9XT6QMTH?Oz!V{3U+_^~b8|BeSjCcP#B3pc z3sGN?$w|%ah^Yc+a270%T;eigHV(B-`wDq8dJsJ4>pu@Me;#$mXS#cWV;BVq(q*KD z-T81pNF8K}4~Q(0(KD?+3EY7gVX5k^vMD*t_+ zK!a(wrj?8_-qrqN10S^u`CUe{X0m6@O4XE51CSMzzm<@)T5nxvAmL9+$ zR0UC7I6{Yh;8hlXg^mJwp{q6{yJI zK+uvjxR8Q%JK#72B~U5h#dUby;ERjMD-TUCfTIciI(*`cnF5Q_Pks~#9T^G{ta`KbD%vgSDD?k11y5$4*o_6QpWkOGitXcm#&2YR$X0vZ~j1; z3sH||#0W+3u(-IGIoXK(7c9lQ4|*_nm@;=Caq8L6!4?pcdHNHaawDD+!F(*9ue@fK zr#D=pXvI`w;xL0|g58xrSn2S6yfE}VGM|0+8J^DB#RZmL@R~hL70Al`g2N!Rm?{2; zSi`ilU-GXQ$N*b1kG;p!g8)ZRX!_z{tgiHcd-&S0tNh%JFf73^W70A*=7mV0KQT4z zg=+7BkieUqQc;)n#2(qPce9VYC41@Z_<~gkwZu2>5O1(3{lYi!oSH_ucclm8BGwSm z(sv^OF`ak^`7laT7?^o*og(rTWMC|~wKMCsf}YUWB*b4rfN_n@DT3PeEyM$F zQ}XHmMUd{|Rz2p3g`%GS8Z!tFJDm1V1*kzxD&8OgNw@T%74M*{t1F_t5v&PTz|;X( znb+AQ!45IC4`DsRB@fSkTzdyXb+a3u0Z9pGufvDVv=Fys;F^v=-liRRxw4PH4|=k< z9VodR72^fS7 zjM(gFk7Edoh-C|eAu0koFrn@H?4$1>LI58=qX{bru4o0Xn3(N40~rvI+-<%DfFGX$ zS|G<1q@DXhw)G$)#SuV^K|xzm*}9%RdmAw1a!u0VkUSoYjW1cD2r+(&xK z%gYhX4^f#L7y`8TiW3_yuXzDb*F%R5M~z=dcvxIAYFGpV5#B5Wi=&H1!{XlfbRX%# z#!$NLSYol3$GLLlN(dNUNqpmun04&?5HN$CGM07rzfy6DtsvWunHsV8k)9V{d@(_( zVP9oq5%KpolXfEm4EGf)Rsd|o!w(~B1)m8Ct@n{0=puxXz@?4%@dr;NphH%#UJYQ; zkfE0@UHUcX!JC+!osC_m5R@;Ba<>dZ`LT7v*9Zc@edLdT^Os~E&~tC@J>bs+{@j}b Z_ip9o=XZF$ZAu$O{K1`Z;MeEN{{f@AXfgl* literal 0 HcmV?d00001 diff --git a/modules/manager2/webapp/images/tomcat.svg b/modules/manager2/webapp/images/tomcat.svg new file mode 100644 index 000000000000..8823f7986e3c --- /dev/null +++ b/modules/manager2/webapp/images/tomcat.svg @@ -0,0 +1,967 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 2006-05-09T08:17:21Z + 2006-05-09T08:37:38Z + Illustrator + + + + JPEG + 256 + 184 + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA +AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK +DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f +Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAuAEAAwER +AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA +AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB +UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE +1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ +qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy +obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp +0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo ++DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 +FXYq7FXYq7FXYq7FXYq7FXhH/OYHnWfQ/wAurfRLSUxXXmK49GQqaN9VtwJJqH3cxqfYnFXhP5Y/ +85O+f/JU0enaw769okbBJLS8ZvrUKg0IhnarDj/I9R2HHFX2F+Xn5neT/P8ApP6R8u3glKAfW7KS +iXNuzdFljqaezCqnsTirK8VdirsVdirsVdirsVdirC/zM/Nvyd+XemC71255Xcqk2WmQUa5nI2+F +CRxUd3ag+nbFXx1+Zf8Azkn+YvneaW1tLh9C0NgwXTrB2V3Sm/rzji8m3UDitP2cVfV//OOfmabz +D+T3l+6uHMl1aRPYTsxqSbVzEhJ7kxKhxV6VirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVd +irsVfHn/ADlxdSa7+bvlvyvGx4RW0EVARtNfXJVqf7BY+uRlKgT3JAt5r/zkD5ZGgfmfqSRR+nZ6 +gsd9agdOMq0f/ksj5h9nZvEwgnmNi2Z4cMiw/wAqebPMHlTXLfW9BvHstQtjVZEPwstQWjkXo6NT +4lOxzOan3v8Akl+cel/mX5a+tAJa69ZcU1fTlJojGvGWLluYpKbV6GqmtKlV6NirsVdirsVdirsV +eWfnr+eGl/lroywwBLzzPfox02wJqqL0+sT03EanoOrnYdyFXwh5i8x655j1i41jW7yS+1K6blNc +SmpPgABQKo6BVFB2xVnf5Q+SjrWh+d9Yli5w6XolylsadbqSNnTj8kiYf7IZg6zUeHKERzlIfL8U +3YoWCe4Pff8AnCfVTN5D1zTCamz1P11HcLcQIAPlWE5nNL6KxV2KuxV2KuxV2KuxV2KuxV2KuxV2 +KuxV2KuxV2KuxV2KvjD8wm/Sv/OX8UTGsdrqGnCMNUU+rW0Mp6f5ammY2sNYZ/1T9zZi+oe9m/8A +zkx+Xc/mPytFrunRepqehc3ljUVeS0cAyAU6mMqHA8OXfNB2PqhCfAeUvv8A2uZqcdix0fIedQ69 +m35OefrryN+YOla2kpjsjKttqqDo9nMwEoI78ftr/lKMVfaeqf8AOSH5KaaSs3meCZx0W1inuanf +YNDG69vHFWM3v/OYn5QW5YQ/pK8ArQwWqitPD1pIuvviqVT/APObH5cKR6GjaxIP2i8dqhB9qTvi +qmP+c2fIFd9C1Wnfa2/6q4qmFv8A85n/AJUSvxksdZtx/NJb25H/ACTuHOKp3bf85XfkpPBI7avN +BIisywS2lwGcqCeIZUdKmm1WGKvijzz5x1bzl5q1HzFqjlrm+lLrHWqxRDaOFP8AJjSij7+uKpNb +W1xdXMVtbRtNcTuscMKAszu54qqgbkkmgwE1uVfbHkL8uk8o/lTPoMiK+o3drPNqZHRrieIhlr4I +tEB9q5yWo1fi6gS/hBFfN2UMfDAjqwT/AJwdvyt/5usC20sVlOq77em0yMR2/wB2Cudc619ZYq7F +XYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXxZKTJ/zmFc+oedNTmA5b/ZtG49fCgpmH2h/ +cS9zbh+sPqDrsc4t2r57/Nf/AJxkGo3c+teSTFb3ExMlxo0hEcTMdybd/spU/sN8PgQNs3+i7Xoc +OX5/rcLLpusWIaF/zif56vFWTVr6y0pG6xgtczL81QLH90mZWTtnFH6bk1x0sjz2Z1pf/OIvlOIL ++lNbvrthSv1dYrZSe+zC4ND88wp9uTP0xA9+/wCptGkHUsms/wDnGf8AKS3AEunT3dOpmupxXam/ +pNFmPPtjOeRA+H67bBpoPDv+ch/yt03yXrdjeaFbG30HUouCQ8pJBFcQ0DqXkZ2+NSrCrfzeGbns +vWHNAiX1BxdRi4TtySH8jfJdn5u/MOy07UIfrGl28ct3fw1IDRxrxUEqQaGV0By7X6g4sRkOfRhh +hxSp9N3X/OO/5P3FSdBETGnxRXN0nT/JEvH8M50dq6gfxfYHOOnh3JDqP/OKn5a3NTazajYt+yIp +0dfpEsbn/hsvj21lHMRP497A6SPmwzW/+cQr9A76H5himO/CG9haL5AyxGT/AIhmXj7cifqiR7t/ +1NUtIehZh+S3/OP8Xk+5GveYXivNfTkLSKIloLYGqlwzBecjL3p8P45i9odqeIOCH09fNtw6fh3P +N7DfIz2VwijkzRuFA6klTmpxmpD3uRLk+bf+cJrrj+Yet2tT+90hpeP7J9O5hWp9/wB5tneunfZm +KuxV2KuxV2KuxV2KuxVZLNFDG0srrHGu7O5CqB7k4qks3nzyNC5jm8xaZHIOqPeW6nf2L4qmFhrW +j6iK6ff294KVrbypLt1r8BPjirAvzb/Pnyf+WrW9rqKS6hq90vqRaba8eaxVp6krMQEUkEL1JPbq +cVYFof8Azmp5BupVj1fR9Q0wNsZo/SuY1/1qGN6fJDir2Xyf+Yfkrzjam48taxb6iqgGSKNisyA9 +PUhcLKn+yXFWRYq7FXYq7FXxRrBNj/zl/NVwC+rL8XtcWw+Hf/jJTMXXC8M/6pbMP1h9SZxLtnYq +7FWG+afzg/LnyvdNZ6vrUSXqGj2sKvcSofB1hV+B/wBamZmHs/NkFxjt8mqWaMeZRPk78zvI/nF5 +ItA1RLm5hHKS1dXhmC1pyEcoRmXputRkdRosuLeQ2TDLGXJCfm/5JXzj5D1HSo05X8a/WtNPcXMI +JUD/AFxVP9lk+z9R4WUE8jsWOaHFGnl3/OI/lpodN1zzFMlGuJUsLcsKELCPUlpXsWkQfNc2Xbmb +eMPj+r9LRpI8y+hc0DmuxV2KuxV2Kvl//nClHP5oas4B4Lok6luwLXdqQPpoc9AdK+08VdirsVdi +rsVdiqXeYPMOi+XtIudY1q7jsdNtF5z3EpooHQAd2ZjsqjcnYYq+VfPf/OV3nXzNqp0D8stPlto5 +mMcF0IfrGoT+8UIDrGD8mbvVcVSqz/5xn/Pjzs66h5t1RbUueX+5W7kurgA/yxx+sq/6pZaeGKsj +h/5wanMYM3nNUk7qmml1/wCCN0n6sVQt7/zhDr8B56Z5stppEIMZntZLfcb1qkk9KHFXzr5mtdUs +tfv9O1S5a7vtOuJbKaZndwWt3MZ4mSjcartUDFUsxVFabqeo6XfQ3+m3UtlfW7c4Lq3dopUbxV1I +IxV9Sfkr/wA5aNcT2+gfmG6K8hWO18wqAi1OwF2q0Vf+Mi0H8w6tir6lVlZQykMrCqsNwQe4xVvF +XYq+Kfzzro3/ADlLa6oxKJLdaReFiaApGsMLeG1ISMqzw4sco94LKBogvqPOEdw7FXkf55/mBrlj +Jp3kbykX/wAVeYSFE0Zo8FuzFOSt+wzlW+P9lQx2NDm27N0sZXlyfRFxs+Qj0jmUd5B/IHyP5bsI +31Oyh1zWnAa6vb1BMnqHciKKSqKAehI5e+Q1XamTIfSeGPlzTj08YjfcsJ/PDy5pXkHX/LH5geW7 +WPTGhvlt9Rt7RBFHKpBk+wgCjnGkiPQbg5m9m5jnhLFM3s1Z4iBEg+hOu4zn3NQOkaLpuj20ltp8 +IghlnnunRe8tzK0sh/4JzQdhtlmXLKZuXdXyYxiByR2VsnYqxjV/zO/L3SJWh1DzDYQzoaPD66PI +p/ykQsw+kZlY9Dmnyifu+9qOWI6pvoOvaRr+kwato9yt3p1zz9C4UMob03MbbMFOzoR0ynLiljkY +yFEM4yBFhV1WVYdLvJWJCxwSOxHWioTjhFzA8wsuRfPn/OEVoX83eZLzekOnxQnpSsswb/mVneOn +fYOKuxV2KuxV2KqF9e2lhZT315KsFpaxtNcTuaKkcYLMzHwAFcVfFHnPzR50/wCchPzJi8veXlaH +y7aO5sYnqsUUCkK97dU/bYdB2qFXcklV9U/lj+UnlH8u9IWz0a2WS+dQL7VpVBuLhh1q37KV+yg2 +Huakqs1xV2KuxV8v/nf/AM4patrnmG+80eSp4Xn1GR7m/wBIuW9ImdyWd4JSOH7xjUq9KGvxb0Cr +5/1j8mPzX0iRkvfKepgL9qSC3e5jG9P7yASJ1PjiqRjyb5vMvpDQ9QMtePpi1m5culKca1xVPtG/ +JT82dYdUsvKepUf7MlxA1rGe395cekn44q+zf+cffKv5m+VvJ50bzvPbzRwFf0RFHK01xbxU+KCV +6cCqmnDizU3FaUAVeo4q7FXx5/zmxpD2vnTy7rcdUN5YPbh12POzmL1qO4FyuKsl/Lz/AJyc8ra2 +sNj5mUaHqZAU3TGtnI3Qnn1ir1o/wj+bOY1XY8474/UO7r+1z8epB2Oz2iKWKaJJYnWSKQBkkQhl +ZTuCCNiDmnIINFygVGXTNOmvYb6W1hkvbbkLe6eNWljDgq3ByOS1UkGhwjJIDhs0ei0LtE5FLxD/ +AJyycP5F0ezQcp59WjaNdt+NvMp/GQZuuxI/vJH+j+lxNWfSPe9rgiEMEcQNRGoQE9+IpmmlKyS5 +QCpgSsllihieWVxHFGpeR2NFVVFSST0AGEAk0EEvn2fVfOv5269e6foN9Jof5e6fIYbm9QMst2af +ZIBUtyG4QkKqkFqmgzfiGLRQBkOLKfx+C4ZMspobRZzof/OOv5U6VCiyaUdSnUUa4vZZJGb5opSL +7kzBydrZ5HY8PuDbHTQDP9G0XStE02HTNJtks9Pt+Xo20Qoi83LtQe7MTmBkyynLikbJboxAFBJv +zO1Aaf8Al35lu60ZNNuljP8AlvEyJ/wzDL9FDizQH9IfYxymol59/wA4P6S0eg+adXI+G6ura0Vv +e2jeRgP+kkZ2zqX01irsVdirsVdir50/5zJ/MGbSfK1j5PspOFxrrGa/KmhFpAwon/PWWn0KR3xV +mf8Azjd+WEPkj8vrae5iA17XES91KQijorrWG333HpI24/mLYq9YxV2KuxV2KuxV2KuxV2KuxV2K +obUdT03TbR7zUbuGytI/7y4uJFijX5u5VRir5U/5yz/MX8tfNfl7S7DQtZh1LW9NvS5W2V3iFvJG +yyUnC+kfjVPsscVSv8i/yi/LTzn5Ij1XVLSafU4J5rW9C3EkaFlIdCFQrT926980XaOuy4cnDGqI +vk5eDDGQsvdvKXkby35StXtdBgmtrZ6Vge6uZ4wf5ljmkkRCe5UCuaPPqp5Tc9/gHLhjEeSN8x3+ +o6foGoX2m2hv9QtoJJbWyFazSKpKxjjv8R22yOCEZTAkaBZTJAsPHv8AlcP53/8Altpv+BuP+ac3 +H8n6X/VPti4vjZP5rzz8wfPP5i+bfNvluw1Dyq1rqWjzG+g0ROZmuRVZDVGHPjxgbcDpXNhpdNiw +wkYy9Mutj8dWnJOUiAQ9D/5XD+d//ltpv+BuP+ac1/8AJ+l/1T7Yt3jZP5rv+Vw/nf8A+W2m/wCB +uP8AmnH+T9L/AKp9sV8bJ/NYp+ZX5v8A5qXnli40LVfKbaCutAWkdyxlWRwWXnHGrheRdfhI8DmV +pNBgE+KMuLh9zXkzTIoirR/kbzf+bvlHy1Y+XtO/LedobYENM6zK0kjtyeRzxoOTH6BtkNTp9Plm +ZyyfaEwnOIoRej+RPO35o6xr62fmPyf+hdNMTub71C1HWnFaV/azX6rS4IQuE+KXds348kyaIZ7q +jaqthKdKSCS/pSBbp3jhr4uY1kbbwA38Rmux8PF6r4fJuldbPlv8+YvzstdPS483apafoO7nEEVh +pcjJbl6NIA0bKkjgenWsnKhpnTdnHTH+7HqHfz+f6nAz8f8AFyfQ3/OLHl06N+TWkyOnCfVpJ9Rm +Hj6r+nEfphiQ5t3GeuYq7FXYq7FXYq+MfzQhXzz/AM5YWmgz1lsLe7sbB4zvW3gRbi5TvSrNLir7 +OxV2KuxV2KuxV2KuxV2KuxV5j59/5yM/K7yb6kFxqQ1TU0qP0dpvG4cMO0kgIij36hn5e2KvAvMv +/OWP5p+arl9P8laWukxtXiYIzfXvHpUuy+mg+UdR/NkJ5IwFyIA80xiSaDF/+VT/AJo+b7sah5w1 +h1kavx3sz3k617KgYoo9uYp4ZptR7QYIbRuZ8uXzP7XMx6GcuezJYf8AnH3yrBptwjXFxd6g8LrB +NIwSNJSpCOEQA7NvRmOak+0eQzGwjCxfU11/FOT/ACfEDnZYH+S+sfmZZeajoHlC8htrq6ZnubC/ +K/VnMAPLkrAtyUdfT+Kg8BnSa7HhMOLINg6/CZA1F9k6KdbOmw/pxbZdTp/pH1IyNAW8U9UK9Pnn +I5eDi9F8PnzdlG63R2VsmndUUu5CooJZiaAAdSTiBaHhP5N8/On5r+bPzEkBbT7dv0do7EGhWgUM +tRswgjUsP+LM3vaH7nBDCOZ5/j3/AHOJh9UzJ7vmicx2KvEf+clQLS78i63cEjT9O1cC6O3H4mjl +FR/qwPm77G3GSPUj9f63E1XQvbQQQCDUHoc0jlN4pSXzN5z8q+V7ZLjX9Tg0+OSvpLK37x+PXhGv +J3pXfiMuw6bJlNQFsJ5BHmXzJ+dn5haf+Z/mby75e8qtLPbLN6EbyI0YluruRI0oh+KigChIHU50 +/ZmilhieL6i4GoyiZ2fbWh6Ra6Noun6PaClpp1tFaW4/4rgQRr+C5s3HR2KuxV2KuxV2KvjfymCP ++c0p/rdK/pTU+POlKfUp/S/4144q+yMVdirsVdirsVdirsVeQfmX/wA5Ofl55MaaxtZv0/rcdVNl +ZMDEj+E1x8SL4ELyYdxir5W/Mf8A5yD/ADJ88GSC6vjpmjyVC6VYFoYmQ1FJXr6kte/I8fADFXme +Kvpj8jdTtb3yJBFFGkdxYyyW9zwVU5MDzRzTqSjipPU1zhvaDHKOosk8Mht5d/6/i7rQSBh5h6Fm +ic12Kvnvz6l35B/Nqz8z2CEQyzLqMSqeIY143UVf8upr7Pnedl5RqdLwS5gcJ/R9n2uj1MPDyWPe ++wdL1Ky1TTbXUrGQTWd5Ek9vKOjJIoZT9xznMkDCRieYc2JsWisgyYZ+b1p5vvfIGqWPlSFZ9Tu0 +9F1LiN/q77TelXYuV+EAkddt6A5vZ8sccoMzsPv6NOYSMdnzl+Wn5m/mVoKR+RtEtNLsrmGWSsOp +q1vM87t8Su8ssS+p0UKaGgAGdDqtHhyfvJ2fd3fBwseWUfSHq36V/wCcqf8AqzaN/wAGn/ZRms4N +B/OP2/qci83c79K/85U/9WbRv+DT/sox4NB/OP2/qW83c8o/Mj8z/wAy/MAm8i6zaaZfXU0sY9HT +Ea4lSdGqqxvFLKvqbFSBXqQc2el0eHH+8jY2693xcfJllL0l9KflXb+bbXyJpVp5riWLV7aIQsqu +JGMSbRGUio9ThQNQnx70znNccZyk4+R+9zsIkIi2W5iNqB1xdH/RF2+sxQy6XFE8t4tyiyRelGpZ +i6uCpAAyzFxcQ4D6ixlVb8nzj/zjB5UtfNn5xal5tisltNE0Rpbu1tEUCOOa6ZktYgBt+7j5tt3U +Z3UIkRAJt1BO77PySHYq7FXYq7FXYq+M/wAyX/wb/wA5b2WsP+7s7q90+7Zz8NILlEt7htqV3EmK +vszFXYq7FXYq7FWGfmR+bnkn8vrD6xr16PrkilrXS4KPdTdacY6jitRTmxC++Kvjz80/+clPPvnk +TWVq50Py45KfULRj6kqntcTjiz1H7K8V8QeuKsQ/KyLyvP5wtbTzFbC4trn91bc2IjW4JBj9QAjk +G+zQ7VIrmB2mcowE4jUh93Vv0wiZgS5Po7zD5J8ta/pa6bf2UfoQrxtWiAjeDbb0io+Hp06eIzht +N2jmwz4oyu+d7373dZNPCYoh8/effyj17yuZLu3B1DRgSRdRr8cS9f3yD7P+sPh+XTOz7P7Wxajb +6Z936u90+fSyx78wnP8Azj5r4s/M11o8jUi1OHlED/v63qwA+cbP92YvtDp+PCJjnA/Ydv1NugyV +Ou99C5xDuWDeefKvnzV9WiufL+v/AKKskt1jkt+Ui8pQ7sX+AEbqyj6M3XZ2t02LGRlhxyvnQO23 +e4eow5JSuJoe8sD81/lL+ZF9pj3Go65Hq7WKPLBbMZGc7VZY+S9WC9O+bnSdsaQTEYQ4OLyAHxou +Jl0mWrJuvel/5Q/8rK80ySeXdA85S6P9Qh9W2spZ51RouXx+kEDD4CwqPfbvmz1pw4xxzhxX5Bxc +XFLYGnv35Y+RfzR0DXri881+af03p0lq8MVp6s0nGZpI2WSkiqNkRh9OaLW6rBkgBjjwm+4D7nMx +Y5g7m3p2axyGGfmF+U3k/wA82pGq23paii8bfVIAFuEpWgLU+NN/st9FDvmZpddkwnbePc1ZMMZ+ +95R/iv8AMz8lbm20/wAzMPMvk2Z/Ssr5XpcIBvxXmSwKr/ut6r2Vxm28HDrAZQ9OTr+P0uNxzxbH +cNSeb/zJ/Om9uNM8pk+XPJ0Lelf6g7D13DD7L8DyJZf91oafzNTEYMOjAlP1ZOn7P1qZyymhsHrH +5d/lN5R8i2gXS7f1tRdaXGqTgNcPXqAeiJ/kr9NTvmq1euyZjvtHucjHhEPezPMJuePedvy3/OXV +fNF/qGg+c/0ZpM7KbWx9a4X0wI1VhxRSoqwJ2zc6fWaaMAJQuXuDizxZCbB2eNfm7F+Z3lQQaDr3 +nKXV21SJmm0+GedgIQwCmVXC7OwIUd6HNtopYcvrhDhrrQcbKJR2JeieSv8AnHD8+9H0SJtG83Q+ +XlvlS5udPinuonSR0Hwy+nHxLqPhO5zYtD2r8mvJH5m+V/0x/jjzN/iL659W/R/76eb0PS9X1f75 +Vpz5p08MVel4q7FXYq7FXYq+Xv8AnNjya81joXnG3Sv1Vm0y/YCp4SEy25PgquJB82GKva/yY87J +5z/LXRNbaTneNALfUfEXVv8Au5SR25leY9mGKs2xV2KrZJI4o2kkYJGgLO7EBVUCpJJ6AYq+aPzm +/wCctrTTWn0L8vmjvL1ax3GvOA9vEehFsh2lYH9tvg8A1cVeMfl95AvPzCvLrzP5l1SW6iNwUueT +tJdTyqqsQ7tXgvFgPGmwp1zS9rdrflqjEXMj4OZpdL4m5Oz3O18seXrXSP0PDp0C6ZSjWhjVkb3c +NXk3ud842etzSyeIZHi73bDDAR4a2eaeb/yBsLlmvPK9x9QuQeX1OYs0JPX4JN3j/EfLN9ovaIj0 +5hfmP0j9XycLNoBzh8noHku+1y50OKLXrV7XWLT9xeB6FZGUCkyOvwsHG549DUds03aOLHHJxYiD +jluPLy8v1OXp5SMakPUE9IBBBFQdiDmCDTe841/8pLaHW7bzL5U42OqWkyzvYfZt5+JqyrT+6LrV +f5fl1zoNL21xQOLPvGQri6j39/3+9wMujo8UOY6PSB06U9s54uewnzt5H8z69qsV5pXme60W3jgW +F7WAyhWcO7GQ+nLGKkMB07Zt9BrsGGBjkxiZvnt5d7iZ8M5m4ypj/wDyqbz9/wBT/f8A/BXP/ZRm +d/K+k/1CPyj+pp/K5f55+15z518keZ/y91G01W01SZ2nLiPVrYyW8qTMDzQurFgXQnfl8Qrm90Pa +GLVxIrl/CXCz4JYiHv8A+Qeia/NDH5tufO155k0u+s3gGm3Tzt9XufUjZuQkmlUPHwZdh0NQaHfV +9qTgP3YgIyB57bhv04PO7eyZp3KYZ+afm/zN5Z0KGby5okmtanezC1gVAXSF3UlXkRPjYbdqDxYd +83Q6eGWR45cIG7TmmYjYMC8p/kVrGu6ovmj81b1tV1Njyi0YODBEOoWQp8FB/vuP4fEtXM7P2nGE +eDAKHf8Aj7y1QwEm5orzX+Rd9pepP5n/ACuvm0HWlq0mlhqWc46lFBqqV/kYFP8AVyODtMSHBnHF +Hv8Ax9/NM8BBuGxZB+VP5j+ZPMs9/ovmbQJ9J13R1Q3s3ErbPzNEoGPJWehIA5KQKhu2Ua7RwxgT +hK4yZYcplsRuHo2a1yHh35u+SvN1nNrXnD/lYl/omiIFli0yB7gBSEVFiiC3EacpHGwAG5zd6HPi +lw4/DEpd+3z5OJmhIXLi2eW/lJ+UXnn829Svtdl1ue0XTjGo127MtzM9ytDHHG5dXrGg5E8vh+Hx +zo4QERQFBwSSeb2z/oXX86P/AC8Gq/8AI2+/7Kskh6L+UP5dedPJv6W/xN5wu/Nf1/6v9U+tvO/1 +f0fV9Th68s3956i1pT7OKvRcVdirsVdirsVY/wCf/J9l5x8nar5bvKLFqMDRpKRX05R8UUlP8iRV +b6MVfLf/ADiz50vvJX5han+XXmGtsmoztDHE/SLU4Dw4jt++Qca9yEpir7ExVK/MnmbQvLOjXGs6 +5eR2Om2q8pZ5TT5KoG7M3RVUVJ6Yq+M/zS/PHzr+bWrnyv5Vt5rPy67fDZoaS3CqaerduDRU/wAi +vEd+RplWbNDFEymaiGUIGRoc0Nc/846uugI1vqXPX1BaRGFLVtv7tTTmtP5z18BnOw9pInLRj+77 ++vv/AB9rsD2eeHY+pV/Io6rofmDWPK2rwSWlzJEl3FBIKCsbem5UjZuYddxUHjke34xy4YZYGwDW +3n/YuhJjMxL2rOSdq7FXYq7FXYq7FXYq7FUt8w6Bp2v6Pc6VqCc7a5XiSPtIw3V0J6Mp3GZGl1M8 +GQTjzH2+TXlxicaLxryB5w1r8nPPM+i63yl8v3rKbrgCVKE0ju4V8R0ZR13HUDO3ywx67CJw59P1 +H8ebpgZYZ0X1xZXlpfWkN5ZyrPa3CLLBNGQyOjiqspHUEZzE4mJo8w54N7q2RS7FXYq73xVTuLi3 +treS4uJFht4VMk00hCoiKKszMdgAOpwxiSaHNBNPlfzv5j8wfnh+Yll5O8qBhoVtKTFKwIQqvwzX +047IgNEB33p9p6Z13Z2iGGNn6zz/AFOtz5eM+T7B8j+TdG8m+V7Hy7o8fCzso+Jc/blkO8ksh7s7 +bn7htTNi0J9irsVdirsVdirsVdirsVfLP/OXf5WXENxb/mXoKNHNCY4tbMNVdWQhbe7BG9RtGx/1 +PfFWefl3/wA5I+VdQ/KqTzN5mu0ttV0YLbavarT1Z7gqfSaCPbl9YCkgdFIb9la4q+cvNPm3z/8A +nr5uCUNnolo1YLRSxtrOIkgSSdPUmYd+p7cV6Yms1mPTw4pn3DqW3FhlkNB695O8l6J5U00Wemx/ +vHAN1duB6szDux8B2XoM4LXdoZNTK5cug7vx3u7w4I4xQT/MFvUJbGzluYbqSFGubfl6ExA5oHFG +AbrQjqMsjmkImIPplzDEwBIPUNahew2Nhc3s54wWsTzSt4JGpZj9wxw4zOYiP4iB81nLhBPc8w/J +Tzn5v8y3mqHV7oXFlaIhjHpojLJKxIAZQtQFQ9a50XbujwYYRMI8MifsH4DgaLNOZNmwHq+cy7F2 +KuxV2KuxV2KuxVjXnzyLpnm/SDZ3P7m7hq9leAVaJyO/ijftL/EDNj2d2jLTTsbxPMfjq4+o04yD +zeb/AJZ/mj5g/KrXZPKnmyKSTQS9QFq5t+Z/v7c/txP1ZR8x8VQet1Gmx6vGMmM+r8bF1UJyxS4Z +PqrTNT0/VLCDUNOuI7qyuVDwXETBkZT3BGczkxygeGQohzgQRYRWRZOxVSurq2tLaW6upUgtoVLz +TSMEREUVLMxoABhjEyNDcoJp8v8A5n/mrr/5n65D5E8hQTTadcy+kxQcZL1lNeTV+xbpTl8VNvia +nTOp7O7OGL1S+v7v2uvz5+LYcn0j+SX5N6V+Wvlv6uCl1r96FfV9RUGjMKlYoq7iKOu38x+I+A2z +jPR8VdirsVdirsVdirsVdirsVSDz3rvlfQ/KWp6h5oaMaGsDx3kUgDCZJFK+iqEjm0leIXvir81d +SfTpdTupdPhkt9MedzawyMJJI4WYmNGeihmCbV74q+q/y8tfLEHlOyPlsV06VefqGnqvJ0czH/fl +RQ+HQbUzzrtWeY5z4v1D5V5eTv8ATCAgOFkma5yHYq7FWIfm3qBsfy81mRftSxLbge08ixN/wrHN +r2Jj4tVHys/Z+txdZKsZSD/nH3TRb+S5rwj4767kYH/IjVYwP+CDZm+0mQnNGPQR+/8AAauz4+gn +zenZzrnuxV2KuxV2KuxV2KuxVjnnbyLovm3Tfqt+np3MYJtL1APUiY+Feqn9pe/zocz9B2jk00rj +vHqPx1aM+njkG/N4/ovmf8xfyX1w2rr9b0W4fkbVyxtLgDq8T0Jikp12r4gimdkPA12PiHP7R7/x +7nUETwyovpX8vvzc8m+eLZf0ZdCDUgKzaVcEJcKR1KitJF/ykr70O2aHVaDJhO4uPf8Ajk5ePNGX +vTXzl578seTtMOoa9eLboa+hAPimmYfsxRjdj+A7kZVp9LPMaiP1Mp5BEbvmXzJ54/Mb87vMcflj +y1ZyQ6SzhksENFCKf96L2YbcV60+yDQAM1Cep0eghgF85d/6nX5cxn7n1H+S35IaB+Wmkkxlb3zD +eIo1LVGHyJhgrukQbfxbqewGe0vSsVdirsVdirsVdirsVdirsVQup6np+l6fc6jqNwlrY2kbTXNx +KeKJGgqzMfYYq+HfzQ/MTzL+dvnmHSNFR4PLtm7fo+2eoUIKh7y5pX42BoB+yPhG5JajU6mGGBnM +7BnjxmZoPQ4Pyv8AK8fk1vK5i5W8g5yXVAJjcU2nr/MO3am3TOGl2xmOfxfs6V3ft73dDSQ4OH7X +kehaz5g/KfzbLpWqK0+jXLB5VQfDJGaqlxDU7MKfEv0HsR0uowYu0MAlA+ocvI9x/HmHXY5ywTo8 +n0Fp2o2OpWMN9YzLcWlwoeGZDUEH/Pcds4jNhljkYyFSDuYTEhY5KzTQoaPIqnwJAOCOOR3AKmQH +VyzQueKyKx8AQTiccgLIKiQPV5t/zkDctD5FijHS5voYm37BJJP1x5vPZwf4Qf6h+8OH2h/dj3p3 ++UNt9X/LnRkoQXjklNRQ/vJnf9TbZjdtyvVT+H3Bs0Y/dBmOalynYq7FXYq7FXYq7FXYq7FUHq+j +6ZrFhLYanbJdWkwo8Tjb2II3Vh2I3GXYNRPFLigaLCeMSFF4R50/JTXdCnOq+VpJby1ib1FjjJF5 +ARuCvGhenYr8Xt3zstB25jzenJ6Z/Yf1fF1OfRShvHcJFJ5F/M7zRY3PmTUI7m8eKMFHvZHa6mRe +0SvV2CjcdK/s1OZsu0NNimMVgHy5D39zQMGSQ4qfTP8AziV518hXnlX/AA3p1lBpPmi0XnqUIr6l +6F2+sq7lnfr8SV+A9AFIzYtD6BxV2KuxV2KuxV2KuxV2KuxV2KvjX/nI7847/wA+eYk/L/ye7XGj +QTiO4kgNRfXSnswNDBEeh6Egt0CnIZMkYRMpGgExiSaDJvy88h2PlDRRbJxl1G4o9/dAfbcDZVPX +gn7P3988/wC0+0Zamd8oDkP0+93um04xx82vOP5meVvKoMV7OZ7+lVsLejy+3PcKg/1j8q4dF2Tm +1G4HDDvP6O9c2qhj25l47r/mfzt+ak6aXovlxrmO3f1I47SF7meOuxLzAURT32UZ1/Z/ZcNNdEkn +n3fJ1OfUnJzDFvNXl7z35Lu/8P8AmCG60uQoLhbNpaxMsg+2nps0TVpQkHqKHcZseEXdbtFsbySH +Yqu9ST0/T5H068uFTx5UpWnjir2HyZ+T/wCfGr+U9O1/yreSS6VdKzWkEOo+iQI5HRlMcjxoPjjI +pXKMmmxT+qMT7wGcckhyJCOudA/5yq0IfvtM1G4VDuscNvqFadqwidj07HMXJ2Tpp84D4bfc2x1W +QdUvl/Oj8y9CmEPmHQ0iPQpc209pKT1/aNP+FzCyezunly4o/H9bbHX5Bzop1pv/ADkboslBqWkX +FsfG3dJx8/j9HNfl9mZfwTB94r9bkR7RHUMv0r82/wAvtSoserx28ndLoNb0/wBlIFT7mzWZuxdT +D+HiHlv9nP7HIhrMcutMst7i3uIlmt5Umib7MkbBlPyIqM1s8coGpAg+bkxkDuFTIJdirsVdirsV +dirH/PXm608q+XZ9Umo8391ZwH/dk7A8V+Qpyb2GZ/Z2iOoyiP8AD19zRqMwxxvq+cfL9n+Yf19/ +Omi29ytzYytfnU41CgPyLOyhqCTqeSqDt1FM7+WoxYyIGQBOwDoxjlIE0+1/yK/O7S/zJ0IpP6dp +5nsVA1LT1OzrsPrEAO5jYncdVOx/ZJyGt6jirsVdirsVdirsVdirsVfO/wDzlT+dh8vaa/kfQJ6a +7qUf+5S4jPxWtrINoxTpJMD8wm/7SnFWA/k3+W48v6eNZ1OL/c1ep8EbDe3hbfhQ9Hbq3h08a8V2 +52n4svCgfRHn5n9Q/HR3Gi03COI8yl/5qfm5LYTt5d8sP6mqM3pXd3GOZiY7elFStZa9T+z0+10v +7I7G4gMmUbdI/pP6mGr1demPzZX+UH/OJcl6I/MP5lNKZJj6sehB2EjV35XkoPKp68FNfFuq51wF +OqfT2j6Jo+i2Een6RZQafYxf3dtbRrFGPfigAqe5xVj35mflh5Y/MLy++k61CBKgLWGoIB69tKf2 +o2PY0HJejD6CFXwV+Z35WeaPy715tL1qHlbyFmsNRjB9C4jBoGU/st/Mh3X5UJVYdirsVfb3/OHX +mKPUfyrfSS9Z9EvpovTrUiK4/wBIRvYM7yD6MVe7YqsmhhniaKaNZYnFHjcBlI8CDtirDde/JX8q +Ne5HUvK1g0j15zQRC1lJPcyW/pOT9OKvMfMn/OF/5eXwZ9D1K+0aY/ZRit3AP9g/CT/krirzTVv+ +cTvzh8tSPdeVNVh1EDoLS4exuWp4rIVj/wCSpyGTHGYqQBHmmMiNwxq58/fnT5ImW382aVMYgeIO +oWzRch0pHcRhUfp1+LNVn7C02TcDhPl+rk5UNbkj1tlGgf8AOQHlS94x6rBNpUx6uR68P/BIOf8A +wmaPUezmWO+MiX2H9X2uZj7QifqFPRNK1vR9Wg9fTL2G9iHVoHV6V7NQ7H2OaTPpsmI1OJi5sMkZ +cjaNyhm7FXYqlGq+VNC1fULe91S2F69opW2hn+OFCxqzekfhLGg3avTbMzDrsuKBhA8N8yOfz/U0 +zwRlKzumyqqqFUAKBQKNgAO2YhJJttp84edta0nyl+Y0Gu+Qr/0NQtH9W4WAfuI5wfiRSDxdJBUO +lOPUd6D0PsqWc4R4w36d5Hm6HUiAn6H2P+TH5xaN+ZXlwXcIW11u0ATVdM5VMbnpJHXcxP8Asnt0 +PTNk470PFXYq7FXYq7FXYqwf84fzP078uvJtxrU/GXUJawaTZMf765YbVA34IPic+G3UjFXyR+U/ +lPUvNnmK589+ZXa65XDzRPKB/pF2Wq0h7cIz0AFK7D7NM5/tztLwo+HA+uXPyH6z+OjnaLT8R4jy +DOPzf89t5Y8v+hZScdX1HlHbEdY0A/eS/MVovufbNJ2J2f4+TikPRD7T3fr/AGubrM/BGhzKf/8A +OK/5HQWtjb/mF5ltxLqV3+90K2mBPoxHpdMD1kk6x+C/F1O3dukfTGKuxV2KpL5v8neXfN+hz6J5 +gs0vLCffi2zxuPsyROPiR17EfqxV8N/nR/zj/wCZfy5umvYeep+VpXpb6mq/FFyPwx3Kj7Ddg32W +7UO2KvKcVeu/84z/AJoQeRvPwi1KX0tC11Vs7+RjRIpA1YJ29kZipJ6KxPbFX3sCCKjcHocVbxV2 +KuxV2Kqc9vBcQvBcRrNDIOMkUihlYHsVNQcVeX+cP+cZ/wAovM3OQ6QNIvH/AOPrSmFsQf8AjDRo +D/yLrirw/wA0f84fef8AQZ21DyRrKal6dTHEWNhejwVH5GJvmXT5ZGURIURYSCRyYf8A8rL/ADW8 +jXo03zjpUslK8Y7+JreVlXasU6rxdf8AKo3zzT6rsHBk3j6D5cvl+qnLx62cee7P/LX5zeSdbKxS +XJ0y7bb0byiKT/kygmP5VIPtnO6rsLPi3iOOPlz+X6rc/HrYS57FnSsrKGUhlIqCNwRmmIINFywW +8CWLebfLnmTzCG0+PVV0jRm2n+rK0lzOpG6s7FFjXtRa17nembXRavBp/VwmeTz2A93P5uLmxTnt +dRSjR/yO8g6cVea2l1GVTUPdyEiv+pH6aEfMHL83tBqJ/TUfcP12whocY57sS80+XfMH5YeaLfz3 +5JdorSKStxbAExxBz8UUigjlbydP8n58Tm97H7WGccE/7wf7L9vf8/dhavS8BsfT9z6x/Kf81NB/ +MbyzHq2nEQXsVI9U0xmDSW03genJHpVHpuPAggb1wmbYq7FXYq7FVK6ure0tprq5lWG2gRpZ5nIV +ERByZmJ2AAFTir4W89eZtV/PD81xHas8Xlyw5RWXb0bJGHqTsDt6s7U/4Vei1zE12rjp8Rmfh5lt +w4jOVB7Zp2n2enWMFjZxiG1tkWKGMdAqig655xmyyyTM5G5F6CEREUOTxPS9Gb81/wA/YNJlLNo1 +tMUuKbUsrEky0I6es9QD25jPQ+zNL4OCMevM+8/inQ6nJxzJfdcUUUUSRRIscUahY41AVVVRQAAb +AAZntC/FXYq7FXYqo3dnaXtrLaXkKXFrOpjnglUOjowoVZWqCD74q+T/AM7f+cTri0a48wfl7E09 +pvJdeX6lpY+5NqTu6/8AFZ+Ifs16BV8xyRyRSNHIpSRCVdGBDBgaEEHoRiqLv9b1nUEjS/v7m7SF +VjhWeV5QiIOKqocmgUbADFU/8k/mp588l38N1oOrzwxREcrCR2ktJFH7MkDHgRTaoow7EYq/Qb8v +POFv5y8laR5mt4/RXUoBI8NeXpyqxjlQNtULIjCuKsixV2KuxV2KuxVB6rpGlavZSWGq2cF/ZS7S +W1zGssbfNHBGKvD/AD5/zh75B1r1Lny1PL5cvmqREtbizY/8YnYOlT/K9B/LirxDWPy7/Pr8pmea +GKW90OI8nuLOt5ZcQakvERzhHixVfnmJqdDhzj1xvz6/Ntx5pw5FNvKv/OQWi3fCDzDbNp0/Q3UI +aWAmnUqKyJv2+L55zWr9nJDfEeLyPP58vudhi7QB2kKepWGo6fqNst1YXMd1bP8AZmhcOp+lSc57 +LhnjPDMGJ83YRmJCwbROVMlk0MU8LwzIJIZVKSRsKqysKEEHqCMlCZiQRsQggEUXiepWHmf8m/OM +PnDyiS+jSH07i3erxhHYFrafuY2oOD9QadwCe77J7UGojwy2yD7fN0mq0xxmx9L7C/Lr8wvL/n3y +zBr+iyExSfBc2z/3tvOAC8Ug8RXY9CNxm5cRk+KuxV2Kvm7/AJzA/NOTTNHg8haVKRf6ugn1ZkJ5 +JacqJDt3mdTyH8op0bFUg/KjyOvlfy2n1iMDVr8LNfsaVXb4Ia/8Vg7/AOVXOB7Z1/j5aH0R5fpL +vNJg4I2eZZRr1/8Ao/Q9Rv8A/lktZp/+RUZf+Ga7SwE8sInkZAfa35ZVEnyYp/zg/o0Ump+atccV +mghtbKJu/Gd3ll/GBM9PecfWeKuxV2KuxV2KuxV2KvOfPf5Aflj521UatrGmtHqRFJ7m0kMDTdKG +Xjs7CmzUr+GKsb/6FD/Jv/lmvv8ApLb+mKu/6FD/ACb/AOWa+/6S2/pir0/yZ5Q0byf5as/LmirI +mmWPqfV1lcyOPWleZ6sevxyHFU7xV2KuxV2KuxV2KuxV2KvMfzC/5x1/LLzr6lzcaf8AovVn3/Se +ncYJGbrWSOhikr3LLy9xir5080f846/nH+XVzJqnlK6k1nT1NTLpwYXHFenrWR58/kvMZTmwQyx4 +ZgSDKEzE2DSH8r/85ABZRZea7IwSoeD3lup+FgaH1YT8Qp34/wDA5zes9nBzwn4H9B/X83Y4u0Ok +w9b0nWdK1e0W80y7iu7ZukkTBgD4Hup9jvnM59PkxS4ZgxLsYZIyFg2q31jaX9pNZ3kKz2s6lJoX +FVZT2ORxZZY5CUTUgmURIUeTxy2svzN/KLzbcaj5Eil1DS9RRkNuIZLqMqDVUnij35Rk/A+3z3YZ +3Wg7YxZYXOQhMc7NfK/wHS59JKMthYZVB/zlL+eWlMZNc8owTWiEmRzaXlsaClaS83jp/sTmxx6r +FM1GUZe4guPLHIcwQ9C8jf8AOYH5ea7NFaa9bzeW7uUhRLMwns+RNADOgVl+bxhR3OXsHulvcW9z +BHcW0qTW8yh4Zo2Do6MKqysKggjoRir849U/MZtX/M6688azZnUTNdNcxWTSekFVPhtk5cZPhhVV +FKb0yjU4pZMZjE8JPVnjkIyBItnP/Qyn/fuf9Pv/AF4zm/8AQx/tn+x/487D+Uv6P2/sQWuf85A/ +pXRNQ0z9A+j9etprb1vrfLh60ZTlx9Fa05VpXLcHs74eSM+O+Eg/T3f5zGev4okcPPz/AGPU/wDn +B7UUbTvNmmkgPFNaXCjuRIsqH7vTH350zrn1DirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsV +dirsVdirsVdirBPzB/JP8uvPivJremKmpFaJqtofQul2oKuopJTsJFYYq+afOP8AzjN+afkK7fWP +JF7LrNjGeX+iVjvVUb0ktqlZh/qcq/yjK8uKGSPDIAjzZRkYmwl/lf8AP1opf0f5vsmgnjb05LyB +CCrA0PqwH4lI78f+BzmtZ7OA74T8D+g/r+bsMPaHSfzet6TrOlavZreaZdR3ds3SSJgwB8D3B9jv +nMZ9PkxS4ZgxLsoZIyFg2jMpZsJ87flR5Z8zxSTLCthqxBKX0Kgcm/4uQUEg9/te+bjQds5cBAke +KHcf0H8BxM+kjPlsWPfkJ+aPmL8t/PS+QfNEjHQbycWyo7FktbiZh6U8LH/dMpYcxsN+WxBr3OHN +HLATibiXSzgYmjzfWP8AyrzyB/1LOlf9INt/zRlrF3/KvPIH/Us6V/0g23/NGKu/5V55A/6lnSv+ +kG2/5oxVHaV5Z8uaRJJJpOlWenySgLK9rbxQMyg1AYxqtRiqZYq7FXYq7FXYq7FXYq7FXYq7FXYq +7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqwT8xvyU/L/AM/xFtbsBHqQXjFq1pSG6XsKuARIB2EisB2x +V856t/ziZ+bHl/VpT5M1qO4sZhtcpcPYT0B2SVFJBp4hj8hleTFCYqQEh5i2UZGPI0of9C+f85Nf +9XeT/uLS/wDNWUfkNP8A6nD/AEo/Uz8ef84/N3/Qvn/OTX/V3k/7i0v/ADVj+Q0/+pw/0o/Uvjz/ +AJx+aX3n/OK/576ldpcalLBdTgKguLi/MzqoNQAzVagqTTMjHijAVECI8tmEpEmybf/Z + + + + + + + image/svg+xml + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eJzdffle8sqy6H0B3gFUFGQwEyEBB2YHUEFwwJlJRJlkWGuv88d59lvVSUgICWmQ75x1716/7aed +Tnd1dXXN1fF6iuVQsjmot0J8mHG7vN70qFWbDEYxN2l1n3e70/FkhE2+G7+bZcMMdEqeS29qx7vW +aNwZ9GPkEXmYw7d951e565vTrN/t80NbpTPptqB1Mug1apPw+K+2X5sLXs7UJvAwciAfMKKbZWJ8 +1J28hOepwbTf7PTbqcF/YPyo6OYZzi3AU0GKwuOzzk1rbO4TjrK8jB3DnAy/CLwYluBNQYInDL6V +GTSmvVZ/UhwNGq3xOD3oDkbjmDv9T63vvqy14UnNXW11u4O/3alurfHtgtVG3nKdbgsW1qtN3FFc +ZfKcfyOv3o7hHXgdf8fm6Nt5D1rKrckEoIKBESXpy2reOB9Aqv7ne7pptTsEw4CIF78ycqXVG3YB +KWRRPCCFl0XtX7UHwEOehqJsmJdlGfAmhiMy9BMlPiwwjAC/RMgj5Q193a2/Oq2/Y+6rQb+lLC45 +mpQ7/9XCqRg3xzBK68202xrd9jsTWASHTbKy4stBs9VVm8i7uW6NLJT8x+o/lQ6V2qjdmsBODbrT +CaEUSZvhator1P5pjfQJroetfmVwR+ALiUJYFMWIWxQY5Rc2HHFLouyOMoA6ScEgC8tUp2TJtKwy +No6E42gTRHHvi7Az16NOu9OPsYLoDnHYint2Ouo09S2Lcm5J+UHWEZYM/5e1/ysAw9onk1Zf2eZs +v5ke9BDJY6Re2Ng+7Hp30FaezX4nT2C66VCBlfz9BvtRHHX6CIPrijyR3ordKTw6HQ2mw/P+x8Dl +U05lEScd9a/78MunOzWajj/dlcGgC6dtroP6SBkFH44mxt5L54C+9uPrA601drrW7Xbao9rws9Ow +Gt7i+Wweu3eXTgjbNGrpY5A/Z/8ufbPcIKi0gnL+0WxwizeWz/BPrz7odsY9fWBDi/67E0XARnVb +/eZ4Nozypw5YofOX1rh8sEzrA1idYWtJa7b/V6s7GBrQOGup9Zvu+9poaDcsQvfR6TcBK+VpZ9LS +N3rQGyIDd5c/a0NsXuipnBA4PcbzEQotPzgrvyArT5ARTv7ptsaug3x/8Hef/OGOuXxPgJLatDt5 +8bsPrmq9ljvoOih3gEm3tC6M+9rFqDzwG367cWn8MO/SuCLjfvgH/riAX76g6W+34L50P70w7ia0 +Pty4kIE9NF0HxRoA54673AcwLfxLAIQV6eA5rrFY6wI7axEginWXnbhBkMauhdZiY/bGt+XTYmoG +gjbTKvgtwHBGpC6skHRYZyNZRnmkHBsc5v+ozTCQqdFmcBVWTV6CclJzed8OtL9hr/GvTgOxURv9 +o/z9cFm4ArlI/vBtN9W+QC3lCQzedvv+0+v2oUMIf/SBgvxAQt436+d/1bpTtYPsPjiHOeceT/4Z +qk8PkqNRzQqCXmtSawLgvweAXQ+Av2qjTq3eRT1o/G8A4n8dhv9JLMT1Po3PTrc5avXVPiayNXQE +mTXq1KcTBDRIHgUX1xIb15Dn4ZH4H95Y6iXNQ4zvOIPp2+2P3xpg5wx6cZvOBpi5/9lt0NawuB3k +QewvuuUBHY7/rYvDNQRpyHFNKoC1A7leEYQ44areIeYk++9DlXEVi8TQHTS+W03n9fXB6vv3rU2D +/k9SwQq84N98WCiRNL/28cff/2sScNztNP6/EH9kIeXBdNRoEa/Tv3JN8yD/4wjizFN2cNOqdf81 +pP6PpcBzXM3MAfjvWs1/rFbzd6c5+XRcEScyYVbk2H/ZilTgF1f12eq0P53VbVYSwgLL/9uWpUG/ +uK76YALqYaH1MVEciM4rdB+kBoN/z9IWF/AvEbYgm/4fl7WbEzgbAt7ggMAWRsVd8pxl3TM/BnFA +uwu1fntaa7fcxcFwOjSRLnmhOGqNW6O/Wu5K6z8Td7bZmdTqnW5norJoMRLhI7MJZHdtNKkPaqOm +u4HBAjfrHmmKnWPP9qilrdexb31GGRFO4CT7rpwOgGNPAwCOfesLQnyx2zzp4vPJqNYfD2uwr41/ +YLpO0z3u/Fdrtk0a2mX3sDZsjeBhb9olfjdNWjMax8RO19PJcDpx39TGk9ao81+ko1sPtajgRebe +uWyNPx3eYOb2X6Mldwd61SYtWHmL2EhLO3/3QaUfAHBtdAOrx/3pstXsTHuGCV8MJ9+KPNX4CqCC +kOHEbbB/TEdCIxfAvIr4qIb55rATNkFb63bGpqZebfytolnUMDasNXWzJHnuTk4ngxn2tP1nDAeM +cX/MQB6RfqG/Wo0JkEy91q31G4t7PfcKYKzb6bfcEzhrdD3Hk9HgWzv7rE3nRrczBJJE581/4Dy0 +AW0Obwy1Uz/4qzUaooN0xl4ANY3BqNlqLm6D++BqMJl7vCrvcRhOp5YDne8djJqjcVhx4JgV74Vu +tX5/MJmtXdnlhU4aHsbjeQ662HHabzh0AXkHJ6ZJdQSML/9nGNYlpdXo0GEwbE4dOoydRmgM5tmY +qQOSzvIOgz6QyEShw6VzqT112iasyaonMOJ5lsQzNj1H5p7RiHXHueNnufNDZd+X7zp0AjY038/A +lc1dP2vN1qi1fLwuiyezNlnaCXA3Ia6bpX16eGzHRkZu1a/fagPj/2v5YPUOnsF5CWYGvPVXq2s/ +yEd/Eh5P6+MlC8Muze5w9DGY8RcrKlO69UDbUbUDS3S3e9/hXm30PR58fIQVdZe6+0jX+yl6TwZD +6r5d0LhnCLDpDPyh1TRDTdHdADVF7xnUFH3noF7ce+xLNJx6bbSMuLHfyBA9dOg6BGHQ6X8MnGYe +GVZi3YUsRO0T5iK2C262PlCKGsxZa2ZMOn8N6hNMZHLsqIiij0532RHDjmMMdjr0mZMfVr0ao2Z4 +Ahq5ppFZnSDsM240+ssOo9Jn2G38Y9BrFvGmdKt1W+G/KPt9LiE77DUYtbWxlvZRx7Fi8NhlOBh3 +lhMZ9oL9Hn4ORv+lcraoXb/BqIO5YA4DdkfhmYJUx3Sx5X01WTkcTJYcG+ypMztrOgNadFAPsEe9 +M+nVhmYRadebrKI2Vl6i6DpYTuGzfnXVW7qsY7M17rT7TugeDkdhYkItoxbs9AlMbNxaxhtJt7/p +uhndQksGc2Qi0Enfs2iUDwuWjAm6dTCJcE4cROSIU3eDOGClsLVsmnWeSQNWdOqqC4OozNl1NeJI +ZG27GZBkxaewS1NJC1nCFqGTs7Y/nnTVXsNh035G7KbOOOtnPyB0wZPZtfLxL/RF2m+N5lyCS6dX ++muGgiHlyGoGEL/dFjGVdJM4PnPZYAJRUuvsRpuKyryyO504WW3icNZHoA6Oxi0cbWS/YOw5/u4M +gVv2v504HCoEcNzbluu7GNQxvcywOt0TA52yxbL72mS8zvlP1D4FtKIxexGz2IiPa6kHRX3rdFRr +ooAgbyk+FTtDZPaO4jc4uFP8ASk7f4AKumrfV3RrybZP2c4HoHRLo/WfVq3/G6P1T+ORwRGWuGFY +o9eqP9D9Be5On7gcUCpbuWwWqc/3ZEg3d69B/1Z2Cq6hmMm9pYmN1TG6Lq3IU+uueT0NEKHrE8BI +14aKA7TTWmKyaOOcItbg6FQ+p716v9bpLpGD2juYtwz/5pZKV61zDojqvlXHd5yhIQncmcHffSWR +J9/pNw0kTvuamdI5zkols3mZpMcn64O/dFtu+atp3arV4V2+0/NvlaY1fc+5iOOEmFtf1r17yzZ3 +VPtndWzOv7UaMuffXQWX+ObKqDS9tAIm8U16RF4O+oPG52jQa1mh09r5s+xdM1KFpRuCI9gjVaCa +2xK1y4+i8gJIHudDXhl1epfoUXDuCvydsich9tRSA37GDQEl50sNc51vEiUGQajMwnN2Jrh5efct +BzeM9sI1UdtzgHhA39+D0XdhpqKu9l7KyU1k++bNuqBWlrphtNdS6MAoLPcdzfW9cTBR5jqvAIMR +Q8voWQG4019iAWtds716q3meThdHxILUpOjSU16e1hGNg/7kBo1EZ3hmqh+FCFW0m4ohNkelHi0Z +C54rmtKVIdNmKbLNL17W/rNED6UaodO31Ulp3lf01JTJb079OmqdqtKp6JyrD6Hqt2WH0ILD6xVj +LM1R4Us2RoN6baLUjc3MDuihrmqmdppNDtkc3hrW+pp7XJOx5btTJGGFmCcLHjv1cWHQqC3OAA/J +wVGsCJWm9GcAXqOju/4NM2b7jYEerxX0B6TUQufSM00eHpHyHKRdOBANi+daheLik2L7Y7HxoWZO +LcDpu53GDKz4ojmgF77M12Lgjik1Griz2jMX2UljC5oYyXL6/FyKZGDcJlbteAPHYmgnMfY/bGXy +F42PnL/EJRM/qVefcHL9fhy955lmvBXz9smf8fPx4CP3Xpju5TyBJ8bUFji5qx8wXHcSSd5UcpVE +bPgii49i79HlPQy95wZkMJgvPk6Wp7e+ZL/eHqvvHP/0kvn77PZodFzrn3bvvuqp98tSMhnssy/x +E/ZOymw3p9lM+uz5hQwVOD4aeoUxv1MKnHxOeAKIy0sBygqAHNWTweHVRSIvj4+ls8P7cG7wKNy5 +vNnR8yOTecxVK7mj5FHDCp7jof9wCBOchdLcztF7JjxN3Cajz29VsTpki7nd0kNXna+R3M18DP1s +snIxmeptLq/Smn/wT2Cci2kmfP15OBoJmQ7DiVvDxN1eeUfpzjLFWs4/2a1lgy9XBykxyG2p47wP +EqNRfFwBeIPnDBv6iunIiqdu0i2XdyzlJnfc6+B7Vyy19gMRT9p/LRyWYpXA0Y34OXphxodhviBz +geNTz64w5saXAM2dFD4YS6eC9BP/gj/9fqa5W83MT/o8erl8LpFJgcbmp4V3o6+R2Plr2HLS152r +gu2kYid/6rWa1OUdjQ49vtGY9Y6s1jqWiuyzsMXF9q0mHe8FL0M2k0Y+fbW9apZM6vIurFXwPwcO +uXbJctKt3KuwfTvsFqwmZXKpfMJqUpcXphW3d/oj/5E1goXqK5P7uCpbT3rqOdxlL94qlpOennEV +Mime/UUEc4/HlXcyKbufrGfnd/V+9Dw9LuCk8cU99VX5py7rh0lDQX1SmEUhpQKTUtda3NszTRqJ +9N6GdpO+jV4++xWbSRM1MZrbYV1e07QqKZ2839hNerbD++LP1pMeel7G25+tG9OkwGGUaUtp//HP +Tq9gNWkg3o0d20wa+dw/eUxcW08qVKtMTmaugMas1rqVa0d3bnrctdWkTO7lJWczqbjt/e5fpk2T +wizatDXmNPh+Zz3pKZPca/miVUv0TraDJ+qk1ZDPhN6TK+Ho2aWcVTb7/J2bW+vjIVOIhlic9HBh +0rPWQLyphTiYlAmZV1p4eqyZJiWzqGuNfjdzr3aTZpjL/RfZetLzn1jia3R1YzlpOb7Hw6m0Xqu4 +nW+VecZm0qcQU37zb1lPmj9rXT09+n36pC6vYdq7vX7bdtLyZ+m9bjfpKXOXHx5aTw== + + + WuC9Lu9tLnF4ZLnWu+HFlu2kd2+nWxO7Sa+Z+5N8Rp8U1mKY9vI4+/ZaenmxnPTl+vvcdtKvaqSZ +s5n0Gbgl8zLuhqzXevU17F3LEm856dt5qG876chbDnj0SVGKGc/qLZPt9C4sJ5WuQluexEsoC5Py +YfOhmb5F39RJ67zfdGhcXv9jobZPpuX2jn1n82stMO/7sSROemyaFIb9+tGYvnhgnnQ82D1SJ52c +BGBf5tfqea49+ZVJU1X2fJ4VBkfj22MPTppY5EnnIW2lh6xpUsBY/GxLVKY9YfMhEysMDoUrRdLw +O7F0fn7SndGoVu/jpCnzSkfJ1kCj3hTQmFmUR75iqqQ5iZXCJgRvDVrvFUWmtmpv4jxIZ7e7r4OY +1VMikSNn1RLbu7N7+5M5e/dObZ8C683s2jyFHdgNpL0qt2RaX62o6bkosW8a3ONvyfy0/7n1YPs0 +WjyPPetPF3Zf4vZv3m3flj5rr3u2T5Pc7mPD6qmqwxQC/RPO9u1C/fojbvv0eqtRP7N5Kp3tnh3e +jjWMfez9yKa3bwMdTT39YLdi5qf1i3Lf9uldJvA90p8uYOzeWz/w2L59/5yJHdk+ffe+RnesnqoY ++5oUh2e2b3/fcamS7dPed+741e4poKoUj8wwtvj8ghOOH2yfNvr1csHu6a5n9/x53x5ju9nkZb1l ++/YFd7LF2j1Nergdf8wWY5EzJnu0r6065oubznSgUhqfqE/T4UPT08r76X7S+FQI3iBDKSnGXDq0 +nwbdcjJ8fUm3Pyvo1EseHctnO0hZ9z7VWj5pxGzMvvFD4u7jtpysVLz3hEUlK5dNIVsbPXkDqcH4 +Sm8Du7I2etwjfC7GSp4rwsw8+/k46wlmbu49wbvXsif41qx4fE/+Kf5WBBL8TntC+bfIolFYbSdL +fFkCqNMBsE4H3+JOVP5AS3yf82h25YuUe5s81xLxIbuVuQhsR7Sl7faSg8wrkOm2vMXtHRWPM639 +rJecOzRnnjQsWvdzKT3R2pKX9yT9jmPpp6pjPzDD6js333o/l9e257730DNwHFHcpl0L2GLRG/8L +xYg7fT7+RtHPe925rFGsRdxGod6gGHHvvB5ua/22e7n0x4V0cHnRisKf+9vJ6GOXV2xkPwjHj0OF +Tpgx101Wkv0ccxER9hWyQfcHWMsRThe84lZVuMw+Nn4+DjpHdb/4KBbOVLs5ujuaCeB0cvBz60cO +s7glft/JU3c5eGhLv9AAt5WrhY1eBVvwmFz+sGgCz3I3hKvMuxVwhFvq4FXfqMA73RFpgDstbT8a +dH478KSzOWKxxV31ZjlwQGPK1l7l72jAy2ZvczPcZZLl4PcODFCqHnS2Y8G5CQKHZhqLGUBh9yKv +mY9KhkeQBVzaob5SNnjLhvRJR1M+zVBMCjr//LREO15z0kBsMMnipEOCFoabJj7Tn8Kbui+gah4P +M9lGsSJqbsX2NNuoth6UNo2P5zPnzSPQlHLTbjReui6ib5GbPb3B38AI/5bPAergdy59EiuTbTdY +FuPA8XF2D6At7yOMYbLq46GvOVZdNfMORmWlbW83ebt9hFoBs5Usdz2jXFa6OVAHvWr8BI6LuwOY +BYWZOPGxp+qLO82MojYDZKmDz1bGq/wAOriHwYqiam3BfLMtIcvIoJMhN7+MjMGrQJbhNfzAmWPv +P8WYQbTOgfezEnDkVC4Fr86fWYFnAdy+LXC4FhW8MQ14hEIJVaojXkh2y53q42m7b7tg+HGjLFfx +3VgsF4yrwvLlulbZjb2tNUlF5ckLu3Fa7CERt/EgbStcR7wgauyddCyf3hbBctr1kh/c3glzjoCc +z4YqaZyvKELnpwzsCxhId5T7S0F8A3Y/9ZVjWDnyleATj6jB7fpmvosK04Rd9Xq1H8K+eiCJy2Au +AhF7H43rsE3xEC0CXXSn7fT55zcI1LVxFYWoJz/++oDoCORSj/IF+i3nULgSAi042o0VR5udympw +aMYyM3xNr8fRsgjNqY4RVSJb4+Q0v4sz31jufvb5emLaq8jwQC6a9oqwd5fXlsHPjXjnoRhR/VF7 +yCCCzmx3/zXL78Tzhbm92t6z3KtWMbyr7osFxk5ipcvNYCwToNzJXZfKD615w2sWHQX3Jvm6Okgu +LwIVpgXKASSwWatWIFnISic8MU4gDQJHugpBWIFyXi6WgJcOPy3F2K6uihhPL3FeamC6vBbnt7xE +I6lzCyLf+fSSfbE8vzkrxcpi43Xd6omMqAbW5sZzeZURT3zZPBUpGYTMpWzNI2G5CmOenTqiw5jO +nU+yVv3mUG2giNrWJbcci3he5mhCXzq8PTmdLX2ojy1VdvcuTyvPX02GTT23M+Gb26Ae7iczw1C3 +I50nqbLSSiYtV2PnRnwYL5dxLu8cITrrWd/SZHW9zeVdOuJ0M5rgTIp9yx6qEY/q+/o5sKJa7HyK +3v0LM082SXYa82JuXz63N70v8s6m90Wmsm5W2RdppMhSJ5UGjVCCVFXtOrXhtM1TXWt1eZeqXTRM +St3u07uB7eYAT17nGN4tCJmlqHR5nY/hiK3t7J39BpUmHQaQSafBroLK+hilmKOWvbJhfmsSgzN7 +n2BnckxlXNKpsWe6GutAY7pqb6lscKmHT7PSaYUMl8HosN79yQmVNbn0aJowdkLFPuiM5zPdeP4t +xqpbu5vB2PGYjvXMrKlFDV3RYYAcTsv9lSxHW5BWtpGtzQYEqTpcCSQlwmsNFBVfoQDpbUR19uct +bDulun1moVQv8Y/NLOxyfD70dKMNe+hLRl89Ye5lXE+lP6Nnw0w+/5PSgjVk0q9zprlXyxJkuLz0 +RjjJFrIg55dx34EBuLwWODmzwcnX+Yp7pQHnMqd5auBNHNSSleSey8u9TLzUJGBlZpuWu2hk0/iU +bHdjEtijBc5FsxuYg3C7qgfIaN3M8eQTX2ZixSDWJ75PbhZ7XUUu2nD58+UuNKOmREvOq7vQiAZr +YyAiAokT7TcIJAxu5k9WtY97eyP8hL1YMGcoXWiWnt4LkxNtTe8LvxPz7ZC9Aj7m7ESjON0wYtgs +m/XxFnbf8XT3LlZ3odlgbN6JtjbG5B9m8bys46/qXVC40Fy0QPG/caGpUR4FKDsn2sp4iphAAilG +QfNzvpI5igezgcI561qmOqgpJ9eGIOJJrDixCyLmkc6zlB5FZ/89UOD2SttkoOR52hnmTT4um2NB +ZTKTUwkjvkxTeZqDhj+WSxX+5DbmM+0V6JbWrnT/LuECdhjzbwpjFnJ4HcI+ufXyixHedRgAgPSx +9/NgC9JcJNERKPNpowXJZO8jUAvi1tYba61Pz+2fxypiZUtZ1j5vC1MfyWc7btLQdT72ULY9uusE +3k6LPTb7Mj416fxrBHPSALdg1o+s+RitzEXCPqDWLubtdEtZiTAe0YTWHE4voo0/uatc0u2+E9r8 +PmcPPM25I7Sx4M2jXd+8hwRWuPwQ0x5h3ES/brj9msb8C4FxC4pw0UpfMiJtlM48noHGLGIfrz9L +Ylen5T6toHN5KUQd7n7lN+GmmY08B+MqLNPrwDJxPDgvjrFCpxEtnBqob/p1Xcflndd20sARYpTH +giJ95OGWGCmEJ//2bIy/HRjcjJJpIyPAAFZXeHAtNioPwugQIaTkTrd4XjZhqyBIgUX/prpIrLBb +gaVgrk1w9fXNPIomwlj0TK4lX+4GxFzZEI0FFnmN0S9AMiHnA8eOfBPR5hjlmQsbu+hNF8SibeAY +xZL9hilnf6WIRxoenI9W2jU7fzLAvWn75eFuo1kEAKHJ8WCVRUB3crLPz2YHqi3aXN5l5A7bvREj +BuULUMeiH3HN9Vkn8Gj1lSvwvjsr7+HaNLZW7p21WCYV3DiiTbThN7EGZGEBIpZdXqtMZmAuBUqc +0IhlktO7Ce8hws3ScRh6sfz8s5JYdjl4IhBG4ddiWeEw9xsSywBSyEos28Qslh++2tblatlzth4S +WN+mxPLzD3KYTfgRCbJsxbKuw6wqlq3yuezEMkpkLcePTiy/jZaI5ZiPnlvSiGUA7snJWG9Nbg8s +rSS7wOrDg0Vm/9JsRf1sl+O2PPlttHG5/7BZuf82WkHu2/mr5rdb3KgljnlDNLa9YTw7Xx9usrMj +ZEY7NA4/Jb7vfPZpMl2tvH5c6qGaJF4/l3cxMv9Q3azXD/OUdL+fDcGumEqKKoSZ9VhERqhSSY1k +kXr43lq+k2pkhNqtCIDSOpUNbkXAmE1oGGGcmAP/zoqMJYtSzn6VXiLTkG59bFKvl2baL0tRQtwd +OKrXKuXMPL3OZz/18OboZFhMTCdYnJf7qtjSdYpUrPMafEiWgw+D5E2/nk+FpMH1Ap5md2iZb8vi +xm1PqF96c3mxejrrCSX5V0/oQs6qhdMXctUT/Pyq4I8wtuWw1DpN6q3xBonDZPm795Ft3J80cC1Z +rMisZmrl40LOPzpD9+rOXi7zHdrCKmpB1ZSUAuviWaWYDYz5XV3Sikdf9fNsPZRYLLa9OrnR92pJ +v+IBM1f3at+zFCtS9BtN38Mhq34u72LPGEsz81bu1XLExX7NA7q1bOW+KGce8lRrwQuyIlQjnoZk +Qz+7StnROBYYG+4hsS/kFR4+C7P7CewKeQ+3fLaFvBizMGwJG6YAL3AY7904Aidub/+I02c98m5f +Z/xEV6PdcyggV28GY3KDSoUCvN1C67PpXATdMgEH1qtlfftV6YECd0z26umUYmNd3rnCf3vwSmsX +Ru8fHRhojLoeG6ux4ytN6vIa1o/3Da0zqeAvKpOqroXCQyKW89ZUGJRYktgflhOHnckDjn2bDew3 +w8lr9uQe2qZbJhVrSTgmNx052vu6OWCvwVsZTcXdnl0aRkbX6hwyh/cpyygX6hnnbT9DJNEYMLWv +Gl1wp9AAZ2clFXcXsOxcqGILHHKYhYKt3yTwZywMroy5sn4Fk6u4R12XQ1fDu6gTrkYq8xHe4u6A +LkccmB5dyXJghXxLGuKjrUyZVwjtq74tAtoOCCT5lsuqvqkSkp0QiLOoKFyIBv7igJgSy5ZU2C1B +oBVHqwb7dhwtq9wISmGp0nG0atCSMa/ugX/Nru6gUSq57OLtMOJGEsu03c8+Vx3sXCofZTVkmYq3 +DsZWj8lYZ9pjIIXWs+NYCQ1HeANRUQSJMvJOA5RTloS9V8geY/YRiOX2sNXpVXZfO791bmB3fnO/ +Ob0WOSR1jio9nDqMUue3qHMUKUq3csSvZ3Xq1PtgLTyFzj7mWo62It5lsXTHCh7QtxM2FTzNU72C +Z3nNCH3NqWNRhjqUGk2gKpSkUist9TbLvL5Tytt6qEs5qXXL+XNgX1tsWcA4x5NpzIu5fVmUPr/e +F+dLgFbdFzqVdaV9obsJaJVqZXPYCj0kv7mcCauVnSuZXKsdQ0rl1YzK2W3zVoeG7k4gClSqOgwi +0zH6uCoq9Zqm5TWJKxvmk+MfKzGo172aBeH6hvnk2CwG7Whsptpb00H7bCWTY3anyg== + + + wu6fUTK4oKJdvHOv77ZHU8OYhfm8tvE8OTGL+bUxRjK1N4IxOtaj62N2xDDGU7kop5dYjvYgrWwj +2wK1GBCkAsngUZwBRcVXKEDSbbblZ3/ewrYJUGKQaSFlxto/ZrKwna65GX7YX3PzMm6bkxhcXmcj +fEkRNOU2uSjLZH/MVudKwJki71/ny1NNV1U/YTxUc2jvhXOq3+JeJj77GxbsfUpLarRXYS5OudZY +tLyOB8jCulF48vlyBrH6ck1aiOXtQBZycUnJMoU8cy0e8SUIdDjitgi01GDPraozV0TgnO7h8qrV +mezF2M4Iv/i1C22+sp7NL5gf69X1Utw/ttLphhGdXWgu+urMnRhrvuJgreonwJhDSRZ9JTR93oVT +2TF1FcbyGw6xEnoThV0uLwL1+xxUFSQbF9oKt50Y9+9iungVwpJct1m2rVOe1oN9EBFLQilreaj8 +9/k1k5mNGRFaES1Lk51EfRXCyW10b+Gum5XuEzVV5tpehbBaLU9+zTxmK4x5Hd2vlJXQs6sQDBHe +Ncuzac6ai648+5cpzKq9j0DRJIFSpoDmrSNWK2bSWSYuF3u2GjreBV17o6rGpzu4WFMaNitda90S +gHCvFLYy1FjZlomWf51XvFApvJHbgBFtiSnt7juhjTZ25aIqX/5tPZGijZNiY+p7ih0rvReM7LVp +bKWbEVzOhb44In2Zr6U0V2ZZLPT9avlsv4zANHePaOr4dEE3k/u2NxB8tRwvTHYMNyla3wxGjr7e +XB/Rtn4dxjugvrfHdkSr+vWRla4zu90US1QXfFhWx4IqfQTFpCFErHGY9eqMaS9ypo6MYLVrYbqm +wmNTKbwYa1mzuJffzK1NpHh5wb9pnWlPU77sfAWU7fpUzq+XLzuWKlBXeoc3R2M2gWNT2NgQ5XHO +08eqb6c6TMtUApRijiUmoK8cH9sEjkmBqpPPwbVCxOO0Olxz18z+5PRK30Ogqv/Y++E2mUWQts3d +mcsioDo51eGiR3Gte+HSK30UwbF4+W5Aa/E5rW9Td0Mpld4L3sO1acz5ywj0lgwZj13re0mOYnkS +WHYXNGiFmxTLYCjSaxfLqeieQizPqjgpxXJt640yZ84olpdVCm9GLAPaIpu5S40ULy8Ry6abJ53K +l9cUywseElK+vCGxXNu63NDNkwRZTmIZ7f3VKj8BbSuLZWqPYnFZPhcpZF0ill1GK4pKLL+NVq/7 +tF7LfC3watmKVnWfCzwZetZpvlpEL/djPmGjch8g/N6hlfsUt35jzfcKdZ8UlvjbiD6J16buU/X1 +kfp+2spP6rrPvaM71vnsr5DpOuf1q49NXr85ywJLWTfo9eNSD82p453DK6aSYuG3zXUFhsiI/YUF +NmRRH9O6Falvn6tu+va5+pjarUhzXUE6HF3h7NOVLK9834X9Jq96XQHF2Qfq/+11BarcV8SWzhuO +xRKfvBkkaqluNN87DDCZV4tjMftQ9eInqX03O1GsmC5jxXTB5fWEpNCb5Yeq9ytJcc0PVZs+U+3y +/pEPVZs+U004/+Y/VG3q5/L+kQ9Vmz5TvXwta3+o2nYtG/1Qtekz1XgTxR/4ULXVl743/6FqE3Au +7x/5UHVg/jPVsJY/8aFqE3DEB7v5D1WbPlOt1b5t+EPVps9UY5baH/hQtWlS/Dr2H/hQNcJg+Ez1 +zKuw2Q9Vz0sfowd+kx+qXjVz2KGM0uZD1RaRRINm88sPVdsBZ7aSaD9UTVtAPqar4V3vQ9Wm8Rbv +6nQ0uWg+VL1SDS/dh6qdSUVZC/WHqqlLluc/U22fb0lLfJYfqnbOt/zth6otEGj7pe8Vk8eXIxDv +7KK6LJP2gAysvrFjVWFHjcAF14nTLQEOZu+y8uwNR3lsyrMp/HYLX5Te4P15enE27dcZnDJLlxdn +r+KDtSnPXgdjm7ky11Sc/ZuoqFqevYGsG4rybAqQyL78rjybojjbDmNrOq1sirO1L0pv8nKFxeJs +my8ZrVKeTZ2j+KvybAqPmm2Uh748m6I4Wz+Va5dnU3A527XQl2dTf7X8V+XZFptoLs6212FWGNGx +dtFKiq1Ynm2gWrvi7CX7QluevcK+/KI82xKL87GGjeyLo/a74l2dVuXZv/8CO015NoWyoX+Vae3y +bIribGM04beV7vbF2evdaj5Xnk2BSrv85BXKsymKs1eOii6WZ1OUGlvl9a1Ynk1RnO1wLxxNeTZF +cfZ81s1a5dlrY2zFCkrH4myn80JRdkwhpV2UQK1YfWkCaZZtu3559iJIFnnjvy7Ppqys/2V5tnNx +tjHXGtfaMicOUq/U6uad2bezre7oNn49m75Wfdm3s6m+J05dNm7HmEw1VrTgrfjtbKdM+818O1uX +yNZfz157N+a+nU19k96vvp1tpcEuq6OmUmkWlmv7bfRPuotcHAvRSRU1sffX8Out9u1sy7s6qRFI +p8jQ36vwuVAZR319CahDBj9//s9VjNvc1LrhinEKL9wGKsYXa0X/RMW4PcY2WTG+gRsOKSrGaW84 +/F3FuDHn6s9VjNN80eD3FeMuh4SjzVSML2YQ/YmK8VmFnWWx8aYqxvVK4SjlQVunYnzdb9itVjG+ +HGObqhjH/OT1a6doK8bNsdc/UzFuWVm/8Ypx+rsIflMxPl+V9qcqxpfljm6uYnyluwfXrhi3/VrW +RivGN1OX5FQxvkJd0i8qxhdy4P9IxfgGaIyiYtxFL31/UTE+R2N/rGJ8lW/Wr18xbvPN+g1XjJMb +QTna6Oq6FeMu7+I3zzdfMb6hGiuHinEDJdOXpq1cMa74+uxUnk1VjCvaBb8ptNmUpq1e97pOxbi1 +72LTFeObo7HFWPbi3YMrlqatWDHuWsl0Wbdi3Hxn15+pGLe/qXWTFeOz6qc96vvX1qgY/81dN/QV +4y6KD17/XtnQvlpOUdLyi4pxu+/ybLZifDmNLVaMr1rfPXc7kOVHHzZXMY7f4LbKl95sxbjyjdTf +524trxg3c5g/UzHucvZEbKBifMZh2C3ar5KuUTG+5t1QK1aML/GQbLBiHLTxWc34H6kYJ2LZ/gsg +m6wY178AssK3gleuGKeIjGygYtzCSvoDFeNk923LiTdVMW6oeqZ1WK9RMb7eDYerVoyvZImvXTFu +cUfEH6gYJxVDd5v8NtKc10+tGHd5rT9xv9mKcVjLrGb8z1WMW0ZGNl4xrkdGqN2Ka1SMk7w+20/c +b6pifHb20+Hon6sYd8i031DF+Er3j61dMW5z/9jyivFFPC2pGMfacPwG95+qDtdrw+Hs/7HqcL0f +YuxPVYfr/VzeP1cdvnwtm6oO1/u5vH+uOlwvtl380vfmqsP12nC9amDz1eE6cHNfYd5wdThdZf1v +q8NNlfV/qDrcsbJ+I9Xhepk26Px/rDpcrw3X5MufqA5XRUK3PQWM/bHqcF0xVK2kP1IdbpcDv9nq +cJMO84eqw80+pT9THb65L+Utqw5fo15sjerwpV8v3Vh1uF4bvkZOL3V1uL5cy69mbKg63CKj+w9U +h1tmdG+8OlwPYzvUWfyqOtzKStpYdbiGO6s6iz9QHa4jw+VIT+tXhy/U8P6R6nC6/LHfVocbswj+ +XHX4YtXzn6gOX5ajuLnqcKcI72aqw/XacIds219Vh9thbLPV4XptOE3m8LrV4TbZgxuuDtdlvVrN +8Ueqw3VEm+5V2Gh1uJ6M5PL+uepw27VstDpcrw0309gmq8PtdZhNVodbSbHNV4cv2ZcNVofrteGb +3Re7T3f/Yl9W+HT3Eovv19Xh+mZb6Pwbqw7XNxtj4n+qOtypinMz1eFzttgfqw6nuleB+SgIz0tR +afAbqAs3xpENCRIur5Yi0WvZf8A39fC6+gdAz23PfvtsU4W8lLdq6NLeUsOD9X1TfQH4nXtFz1Wn ++MA6kFLq4cd8K/ZKpZwLGFuxlNMOY7T3XThjbPHm3xXu7Jpbmq0JvxJIJJL4RpWO5Py9dFtmZZ/X +Z4unFYtI56xXE1Br3OJmF+giX2Cnrfek0PlxpQu5O7jSOZ3fwlK31/RPfJmRnaZ/brLTf/0V5uxC +GZoN56er9l3L6Wh7EziAt2AU/8bpCON5V/gyi6PT8dzW6bg8D9Z+N/ZWOjQOVtL5eldSLqust0gJ ++90nwGcC0eXdxM0Jnwvp7fMItPkO7xIELknctkWgrQ6DxetOyWHUCMRZsHjdyZ5QcWchIS0yRsft +JbVv/I48pKlsoPPB9i6sdn+NrMCLjX/172KzXzQALrFwsNcrfdbzvX+LMccP/tH5LbF6ekPfSL0g +Gd+/zxsnIFmkelvd1EqBJ0c/03zKnlLLY5eihcXrv/w86sw7Olfsxp9UJhkb79Iwv2aWt7UPlj+5 +DZhvgFlnm2IlLSrqlFNHK95jJftjtsaN0/nVE7xtMXbk3wjGPFq92C8TqfMUSWR0X/xEoH6T222I +8eWtfBtr4skUNVoe5XFS8rF0nSYd0LV6gcZwScIClsZaHNy1b5zGuBB1kY/L61RTTCdzl0vcue8j +n73HzO6W9S+KwNJ17fQaIrxrfWm39kZzwYOLshSbSvDakrj+FWasW9/EbRFK1fr8EV73Vg1StR7c +HI2tKn2tZK9uv5AR69Q0tkLyeLmPPNnGj4iOHqoybmdBp+9+uW97HdLqYWyEcF9nmY66pWFEuzIP +MuLSa3VcXooRLWtT95ORI7simFtLD8n6sVAQkxTizUVzNgDuHA3x2TruF+ssgAHQ3j1DVZu6nywu +3j24VvnY3WB5berc7juWYu+vaT6Z/MmEOjZTm4rl15qz9LfyBavWnWtTaWksvpAgYXYiLctAsPlm +vQXfNDtCKArlZoFxwpO1ezoAj/u2yaJs9jlNYxRa+Rws13K30lVZSyuKKb9dSx01A/N3o3fcIYyr +3fdg/33ku5Xuy1peiK1V1/76kga7FCVLi8/5S+freB+svl+JdetrXZplhSyrexStXKSUH8PEEe3C +H1Y0tsKVEbWt0xM7sXyvi2UHbkkplp9/lovlFeRLbeuBxqtpJZZt7iGB3ac9dzRimdS91jfgc0G0 +2Yrlxd13/Hg3taavHz1Lnny/MbGM5dcgljdwi6ZStb5ULK9EY5eOFzzYiOVl36zfnFhe8Chi2DVo +K5YBxlsa08wklu3X8uAslqkrV3Ur6W3kqFgtfPPcuX6d27TcfxttWO7HfLQXSnjnIolLyuv7v7xq +cd4Sf1jBEaKOaIs7kxuEourZWUxi/brz2aepYLfw+r15lnn96oaPECy3xOm8fh/psIONiH4YmysX +lpU0fzpduDCfgeuiuNOg+mu34sI9ilY5Sb9wK1Yt8y3Xvn2YlK7TS2S6z50fUKvXWi2PjdiyTC1a +48KF+bNfpfHzr2JN6kIL8y0LQthebL2M2w7Xg6nLWPZdiHnr9TZ2YDsfRcTcdr7ZjTqqxxHxWKhk +6weNXrLfCA2Ske700iQDyFSpbktkEoff18+5/d1rjbk0kruZj6GfTebvHwuzfQYrSQ== + + + r7xXT5G5+/uV3l3vrNRqqTgpXKe6kodNX92XWrnD7HMY1nfvy/lLXDLxk3r1YWWKYg7MWk8aORyx +mIhXSlOsjz6TQp7dafY+de+ZP1zFTCNWa2Yb79niaMSNk5799qh0EPLGdvn7y0gyIgbGn+cHg2nN +5d07a0ny/snTzuOW53zi9yRzNwdb1VcxtlO+3854v/vlwl7rcxgSy4Wft+h3s9BOfL9ffeZbZVm+ +ej77uSuzw/xH+bPU6d6eHlxO795O/b63t3TA/1WNfF33ioH4h8s7DDwmJiNv2bc3GvE7nq1Ba3Dg +ZXY+4/7HQuMuIAcufcc/O71CjHu/OUoyh4dbo9FJrOjZfzm99HCp624g3hASTI45OWZylfscc7o9 +uGJOr68+R6POSWg0/TwGbjneC17Vw3xBjiXL2+09UkIO63vOhqKF3S8pepLOp7rRUoAUb8NKKxWA +odUMWFy4YJHnoOyky2t55YK/05U9ga5Qwul5nXRjXG2vlDgMnQlKQfte6ufGGsddVih/3u78jBJ9 +8crl9dxf5QMOKJq+h3f2d70PxR0p3k15i+XTi7338vmRIO9eirCqlCdZOaifYIF8JXB0ELpH4KKJ ++MtrN3Ph852Qr2NXS0z2Lvo2Grcvthmu9LydjL4kWpnz3slDIjZ8OTR58oFHXv5kg9I4LBcvSp3Z +6TXQOciFYlo/FC6vdixeLnXxznDdyWH6U7yuwl6NX5OwF/dz4zx5rcY2433/JTf1BD+/HvC+i7vX +rCd4ef/g8YVqW3jLxQX+iHtCSb7oCR3svuI2HXtC/dKzJ9AIy4iOSPKm8ryXrTPxKBk7es8zTXLz +Bfkt3notfgL3vffBqrYP8Tbgs4+XTGs0CiZ7g8IYVnX/g23NXDrl2Up8bSe76U7tgGWY8ftupn79 +dpWUgsNBIHv3cAadH7ZgvsA7ov+ceztrBhn2ddub6l2/SLl0clpLDMdsXfvi57HYCGYbje39RPzi +G459Pj8hRhPDTSfbidF2c5QKSd0rht3zt5Plxt40WdqX7pKDQbiAiuqOWM3384nYdfor+x5qTxKH +J4I3kyrcNXAtUZc39/52PUxWMo8ckHvwKPPBVPFaD5kla8U/3xOHomeEx8I/a+sd+hOFcqrb3wvh +YLe4k5HU90/Mmyold8SUMAk85XyDdzbzEXq6Jpz/XQCiak5wnGrm4+U+muomujyOSOJBnvTnU2Ma +uwzwT0gHeQI8kFfoIpe9vJNynuBNLvfmf8qLk+FrEfbqJAiAeHynk7dhHVb1ICBPvpgGEvGCB+/V +3d/O5PNnUXh7Ozw3jm9P25zMCBc+zqVboaeUEOn7CQloe/rgTX+Gzptn22FvVN9nPPvv51sAfI8N +ZILeac53dn4eu0pNnzLn54ldvS3e5qrfqbPb/o8yHzethmCRzS29i/gofj0lbz7ao8zHwc5btrH9 +fUKmgrUAAP5c9uc5ltv3BwPiduyhnG0ED0Nzi7ziYNJUn7iOdBhT35c3B0AHzyO8iyBNeFvmI3Uv +5TKRRw4IqVrIZTk/6DAGdHDbb98JPnXwpfzJtqMDgDa9bVhQ/vF0YAD+KPdzRnYNtuSplBhNc3tA +DNU7hvdsSZnw9WeGbCLoYx+9+E6y5Km2YRnRi8zF+3gX5N3RC8MFW92UmLj/IfKVYQfVLrCmh0ny +mg2V1c1pDktkf9UuqXorcfdxew8Mda+QKlVPGYIMQmMfsIM3vXLu+azQSl6nU9VM/txzt/CgzVyQ +033kCwXfxWrhNpF7be1UM+fdwFXmYzA6VhgXHy/vptqPpbv4x7SYRsJ9hrOPh1PRrggLm3U68k25 +99PDoa+We/fHMhhuu1ZhfNr/zgY6jxNxO339tgCS2JzAvpzswMjnfTwvd2Qtzy9HL/nsQaYd8tWT +khgN5zLVShDQFn1iuEk8KRe97Yf4RapxnzsZnxZz73fVJChTFQGPVCpZDu1cpi+P+mmVWzQK41Sp +dH2bavSnr+q+SP32M5+tlx5QF8yHxRPx4TSdvREmKgnkq9108z54n/kojd+QCd/msuxoJxOs/eAK +HkOZg9rhbqzd37sFuCK+TGt8Iqi48wgyUYJAt+S5wiT3FryLoRpbMpoNVr4prnAPxBmXcP+KtDOD +fLGYe+WZc+nPaF710CJHAz6dq6AElMXbI7wVMHm7G38nnfAukXt85yLV9VYq6XZ77AG4LyUET8o2 +Iu/+6F23cKqfl+hd6akCP3q13OHp0ylhZqQtdtWOd2KdV+kDHrCwFvVAAgCZVmfwdOo9ff7K1ivZ +iD4YcLTUrfhQG96R7UbiLGdaD35+9ud76ps78mhC5GwPr35pprrhmwBYQS9+QmPZXaB5IZvztU5e +koPP633VAtMfNL+esrVaRD6Wz7mBgtnLs7vvxOi0B/qD/7WUCd80OPJ0XudPI59+gbW0PytvoFsn +jyx02ZjBLQ5MagtY6+1OIj7oVVPXk9o43op5+4oFdj4efOTeC9M9PUtAb5vd1nj80yNX1YDiGN2L +7Q44ARTjn7vEwWQ/E5AjwiH8dniROBgPT+DB5UVmWitew4Ojk5y8c9NMv1xsnxEFK3oX2rrMvV/u +RlWn4zAJVtL8Mb0GkLlptvF4RITjDUixwxGezw7uwTnoAjxPfBd44ivkyEXjjYeQqV82sC9+iEdf +9fNsPZQ4V7I7duIfZweV5DUzvERCO1dE+bTH12HPfyrkphhyKxdwgYc0UWjhnAs+YKg/DaJsHIbe +cwP99i5JHhx8JMuT4TSZf/RLsBYkP0PrTf/7OVvf3X87O3rm7xV1+PrH30hdT/d8yMzOMh+vW99n +2zenYeDyz/d4MdBPznfx5QO0DBh1qGmJUCAcXCZ44PKysOXHMJjMAQNsbwMf37uA/f3ZBUWm6kve +dIf72cb3jwwaUDKEU3WIiILDEHwE9p/YIlRCrhc68t8HOgQtZLnECsTPI4XVbFv29DWMBS/ZrVw2 +44+aepqdFWyl4wOQmE9QjbY+U6FYZkdhlLMfYBS2PDDAzVaydNX6grMvtKeNeRLA+wKkQeZi/2kH +hmgNDVpfIwVSG9EvC29PN4fBx8xzLhMUAwb5eXJzeJ/z7b50svVyfpoNFMJKpXDKm8mn3vncc3On +TXYf21hgAPEJKMHXHrmbazzHW7f+TqrxszVSucXO1jjnL3ZigePo3Vn6/Tu1n2m9Tx9zb5PPbiKW +89ZmD7ZcXmApL8cKD3kU28+gj+UN2gxYP2I0l+kGFUGIBzslTvKTo9xb98CTiGXjxaQcz4e03Q0O +QPdoT3PJXiyo3X7Uvp73Wq9iNhivwQvtJMb+h61M/uLxPc3e9sY5z345pD/AL0tefLRhzyPRlBhk +H3DEi9Q3W2tkcs+X7Wzg8/URT+A78TilO1/iAehM99PoiD/rpAqPgwc4i7FnEOSfO8Ajr4RE7Opb +Tl0Xhx1QaWK78tfI8+TyqqSksLAPJtXtDUPK0dUV7MdGixmNjg6wyzZoLh95llC8PphisOxGBvr1 +WFWQcjdbOX+1OAEau06HjomCiRufRMM9hBYKo8O9ON8ooHuzjnzdeAfw2fYkfpLbicRhsNkkl34R +X8H2jzi9dHkNyuTifEXDDVx2OFHuvkrvnGRtVwq7r6z1kbuwWqvFzNuOKx3sm1ZK/DBWawXb3pva +ySxdaYl2pWBX8udPPaPF0GyXTLhlcw+n7MztlIpx9YMO2mw1NCC+iDwj/EB7UM+bpB2efXgeb3u8 +X9lG9Z1LdS8OemAWHkfQ45jCjwsUcu+xgZDzyaI/GwoMhrqNddAJxvAsPsqJ+5J3y/Ld1w+s5AJG +ko/GOunMJ+G1KPJ9yDyD8Y/g6DHdvLu/VC3e42wQDTcZTIlTwGKd2cl8fJT4mR3rz4TD58E4c/Lh +gS4Hr7psdnnl67v2de71eaAIodgV030Tpzf8Pdj7500Ypxgh3hC8bxF08OHTa/K6cPaIqn0p984l +IwqNHcZ7Nwx3dj3NnE6irVQkE7rTnwLG4o3rS5BExQFIIrkGnLgxVuAhasD0nTXc9mcECeSnPUhP +I5CzM5BA5zcDlRmXbYEKxLu3V6uDhLtf3O3ZAKXcI3lli6f52wqvwIZ6207ExqEBbvxZNnA1qIBh +cwUWX7LYK7dz6cZNGv0+j3jkuHTnKBWwnPSmx71qzuLMOHMR2N9OCfdcCk1PJlke98tAVAfTufUl +eCLF/FOQKhcPZNq5Sf3PhnNnNAA7JQWpuWSmYYlyBanntQcyKdiVxmljrPVOT45/aHd6YVKuXTJE +RgxoxS8bF3S0Lq7PFqkwaeJ+v0ZAgmMWfCVP0T+mPce7Sp8VoEr78Wcr8rICCXSh70s7kF4JSGhZ +2AGFmDABpT/FOzhfaEGC86IDdVKZXC0Hak2QXN6VgNJBwrinn5YdWZz9ZQxpTXZEfH3V4UrUbzpy +6oGc5J4tJ8XjiDT2ctFWVNq5jajaHMiX2rJzDgfO4pRjnpI6LF7IKh/j7jcsJn3Qd9+O5LoWrOcE +bwS6ACm2ucNne/RA66Mlqo90OLQmm4Hd/xMCBUjqc6qTlI0UW0JUR5z1sMVne0rV1rJkWDs+XXz7 +oT0AQGP00LbGtsMyuZR4bQuryzveyrUZm2EHnvWPK8A6O654e7bNgV31uL63bY8rruX0LLVMLFf7 +NsM23myHBQR9GbgAcpiFYV/smMuLDdWW+9YnrCAc6XFkI0fgT24LBQt0vNpsXXVgqSkFC4G4znDI +rebKBAkuVWXPLSZozaLHX9n6wQfGNkqvROlW3kD/fOr7MjQBdf/5PtvYFncUV/j1q3hMwhHogVfM +WX6XuInU3y4+vtEPc8MGvqN7DJtP9k4fTo7qSeb4oqc5Z6Gtuu9ppD+fUt6Z77+L8ZwfLb7kF4l3 +BTO7NL8QrL8bRhfNceajUsJgzuN++uPos4iGu+KwP6yL8X7q23fzEW/tiUXcqyd84wIMlrO+4myC +P6VZXOgZzDrf9uyrGaL/Z2YbfQEXzDJKqIfkTighsVz1VQkVsPnvSS7BXNwCHsKP6p97mXsllKV4 +/LEtefotkKgRWuJq3OgcgxhcsnJ3GwC6ewkYbRowOQ5Pu8fVbH1wFkoWm/GmIVjF+/vbYMfd7mTO +u7e3JscnCQShlcTEo5pDNzhGk8urhE8OQ/et1Hfha6T4B+eCObXk7e7hMWz33fViQOm+q9u2JCKF +33ycj0lxZ+mhQiL5armv7fTkSwmu5E9+BD3yg9vZSXde3zit7TEOW7LvnwWw9mHNzD5Sss/vM8TF +Lr62f8haovf8Yc04PYnHPo2F1PfeaU4z631eY6ArffyilmvNXmPzVZT775feLkasRMXi67xe9IHG +RiLG6bbk72QW0ObnZzHTBw8cvvxohp2JEj0ddRp+DdZ7KfHA+MPan7tg7+d8ecaLzulLOHK1XYUY +mrvBfG6fO+a0bXyU0Oru5N738i9wAo+2SWQE3ZgZA0sBGkqA/f3QS1Yus3l9c1xezQ== + + + /+uVsVMcWj1fydIVaJkY6tHRMYsgyXgOCkrwCO24+QfFRLx3nDYEfV74bBdsZOKA04JCxEFV1UJG +4ihRTudvQA/xn5MoHjpBJQw4PGldUl9JqfH4kDl4KEWVcNosGAXblPlIxJ+TF4Qnm4GKYrToKNmv +196V84K35xzujVvtrG/rrp3JeT8u5gNKF+J9zz9IDobDekqYpkqmiJTCYT5zJ+Pnu8Dx9uE2vnMG +mA/siNssc39wWf5GHfVkB/Sj/SfVEfJ+fgyc6mRsiFx2vZWSIZQzH4QBi6/16v8A7SqHmTPbEvqg +B7MY0d3Z9s1RHInhEihmb0LcEYpD15wHqmVuzYIs1/qkuJa5aTc+Kbt3gRbfee27uBjb8l9IJVk6 +BS7YqpDAuPoOCcaNIoVUKB6PEe89MArvNF0YTK4RzKF2Srhp+nN6U8HARA45jMLCSGvvK1lJ336Q +ZcyOoV8JtxWfLp6zb8PmF9DiVkQbjASC2Ex2J/R5z3Ah3yRZDHUraswf/yxdle8wMuJvXish++/W +C4xTYGup7vd5WQvjA51PfOHLxfi++kDc/vGUT4c7hyL63bcQqYVovHF7Q4QWSfZAWQm41aJFC6I1 +1u6nSing0lupwmMpqbJWRbqeMBhFD6NQ68/kx2Gq+yzHCAx6nGYWf+metnOJduJZOQJkG9lU/dMU +0VHjNPL4hHwyQOmH9/wnfhKjQ+IdVN+VO57sXn+3BCCxTy6vOTfk+DTvI9GLROzB10lxX2E1Jq70 +C4Z7Sngk3SjHzCGTdGtag+mTLVX3eKgWsvVws4VWUqe9rTiWQDiO1SDN6O5UTZBIpTygMBwntFCP +b5cczePdcqwTOK4OFE1BCfoQl+xD7eeC8Omzo9c7hhAX0cf0AI/gzZxWE/Vk/8QzyJw/idtKyi1/ +6BOSkebpbeLno91XL/h6mISVQodOMBZAPWqQrNzv7GffxWHD0M/iTntxB4DK/aAH/gsTaUZqoHP2 +A0OQmon31MzWvc26iQQQTxktEUqVj2QtO3u5zHfw9Oj+I51guO3vj6z/tdYGPaMyzj62Jruq0Nv7 +8CYOI18dZMcvChcAZCW1lL5sH/NdSMpQEiQJxyditUI/9fl+iXZlJ/n8nq2XqjPZpgV97t+PK6nu +Vj9HojyyeOpvYDgGtbCHDHkQPbwX3zDtpYMh8lI2WPk4MMCFn6AA6v0Za7qlwj7k5Hvi8CwdxsHi +amhY3d9YshxMgLLRqUxIsgtqzJdaduT4v49dAsMKbiHKiu6Dm2m3NboeddqdvjvoirsOkucse9tv +DnKjVqvS+s8kM2hMe63+xB1zHyTL6fNzCfhrY9BsQXfvXDyn8aFnOuppV7QxX8sg/aPYe3R5SehT +zeES42PMMPWBrGuPF6LEx7X+affuq556vywlk8E++xI/Ye+kzHZzms2kz55ftCjq0FyxBKLIGZQV +AAHdPji8ukjk5fGxdHZ4H84NHgVgrNnR8yOTecxVK7mj5FHDCh6NISo6z9dKkfOZwFseORfqB6ks +d5YgQfPEx23lKnkmvl9RRc5Re1dVBEWT7/a30KR4UyLit6+5GvD8pytDogS2SZXn1H3qe1eaaCcE +j+VXp6bwx5kQ1APgbLDdPlaYkS9br3QOM/n84d68CVMAtb8XxByeoprIo6RHvEQTsWtfCznJKVE0 +NTWz9pT6zpwxKg9nMi8khIRhp/RTCxihvK9EX0h8ngTkhffQi/IlF9x4NVyvpRLqOVN6WyzPHhzo +gRwlf6ReOdxGsRp8ieTejpM1+4w5e6PncXAJyziVVaEU8vWUPKrKxVRWRYh8+ePyJkaNMFGkJ9lm +7LhnYbj00UwMn748+2LpVrl4OxtnlohmCAKWrYwjlzd2WSudKOaBvtP3l8NzAvycDXSHcfeTH2Nb +z7eX8zWeZ+zyBZTP4uOBIeqO6QioVPWOMDmvWwWB+CIbktfQqkp1Wyk20bseltQw3selrKf7YZxw +V8lHNybtFB7A0mC+CWtE5OfwEz6RgYRZmSJIxZNbEA3j4JzhkhxlznOPW9qfhwB3/uHDuKDGjxfU +ndeQJky07M3rglgiGYGouIeeMOVd+diaoOkWJGW20Mrkdv2lVOSuNNEyygw+EYwEHqN0vP/J+c4f +bmabc6mmuoYS6J5gdmD3MTdAswwq9Rk6QpqZ4XlDFfBmMauNPDg6U3TDxWS5C80weQ7PJeeh7UOU +Lu1oPj5ye8e+M2OXe/kj0w7kaxa2y2G2cfyyDasPPphAArFaehx2zGL+ZVwvgfWSDYPpIbfErdxb +CbpIrOZveX45er5KH+WSjXYDU+Su0vxR5ZjYuEoCByba6VYMWct9kFhBhgQ7dvBzBiRXJCSSydQu +Lm4T8ZPpZE4rP79Pd46OYwoTBu39OxEvT4qpkPQVSHXD7f1ksfFRQLjK5FNkzcfsY/MCY7Avx5ge +gihq+NKd8SFL6iTmy2FN18KkNPPg+9p2UnSmzE3760nF41vhKskcTscqHzt4BEpW0unZg+3xYeA2 +PlI6oXZE3lET4zBZbhZZVlMAX/NZ4xFXbKDgI1ZonKksDNtaA6EBp3LBelET8UwJduoJROvlOtXL +qZuMVsDgIvui/4kZna3UWWX7Ffc0jSkfQWK0n2DKSLo3S7W7RLfLQ7J/tv8zy+vSH1zMvvU10JBa +64FmXPlBsVVNxCs3Y+Xp3AUcqyfMcIUK+t0+QUQNt+OtyvsNCNtaz1yaUrmYTGdyXy9YUT7seDHF +xN3D0Yib7Hr8h1eCJ3Swd4cp9BnPTkY6nGXUn+CDC0/wZ3iND04wwb7pCV4+nHn2f7ZHsC+XLyYX +J70RtroJhqkzqxphq5tgcF5WNsJWN8HURJCVjLDVTTDtoodVjLDVTTDislnRCFvdBMOSjFWNsNVN +MCUpfzUjbHUTzOVdNMK0mljiifZXR6TqoZ87zDVZs0Kv2AbG4rezTMszDOIZaiTuKue1XFYqtZRy +39zsURv3PEw+5KoU3UrcbS5ZnmyVcZObWPNxrBTKGzKq2qXXWXrdrVVaHGoXmL71Vpm8ZANjfnfG +wpZngiUcM8Eau0ZbbHnWG36is7Q0E+yOIhNMS2jzTHzj5ZlgzPlzMKxFkpalCk7rxR/TSkHuO2e9 +2a50a+v45HTpSm/nPnW5NOuNyR5eZx1W2tqKHNiuVNt9ZRmpg0untMiz3TNjcdjJaSATDouPxGhQ +5JlS/aA+uCfSjhTkaPJOed7bAuMk86Vkus1y2fb53fgTO59e92Wwqy4i4bieJ2d61+VV3gbl9gvs +pdhuLlnqjWcxpQZ7+tpmQOa8jlDNO8/6b8/HimoL6ucNBu5bWAHYIlFivZgJU+7ec8kn1gPyI/WK +7lq9sH2+NsY+2sy1S7TRZhJrNiVOhUNrRZtfLpcHwNHe/2XGCgVIJPBgCMlerRnsXxLqrwZBVm48 +L6+2dXpuymExpM44pUYZP1JtMPvSy/PyDGlAq+TldW9WT3UgsnJZvgdGnH6dFApr2VwWFl6BPbYC +yeWlShb8ZQ6P8ZpCI1Cx4mTt/MVFkDBM+/vEorvBcna0/OwvMKQ12ZHp7L+8U1C/He2/2uaqkQJp +Mu062WpW59wqVw3k/m+z1Shy1VAik1yamC/4RzKyydGb7f5qRFW3T9SiSP/dlECZJ6kW8fasS1R2 +qU/vZkq1Wsvq+dPvS9M1HdOybaFtLMv/ah6YBjWnmD/ZDfvxG8wCT16WXLrmcU2Y8pXNa+ktT5dH +dvxjMWwSTbysDReoPitrsR8W7Zd31SF06x0ky4+eSYp7v/wislmpNrlKH7wSp/l83cmLfrOBegH6 +8rsNfn2zAWDM+W6DX99soAbpl99t8OubDVxeirsNAr+92YAU4TvdbfDrmw3UhLbldxv8+mYDl5fi +boNf32wANOZ8t8GvbzZweSnuNvj1zQbouXK82yDw25sNMC7meLfBr282wFJMx7sNAr+92QB23/lu +g1/fbABrcbrbAAxgh/sFHC81QHt/tQsVVptUuc9gVh6/eKPBBu8zUEvKrW402OB9Bvq1OAs3GgQ2 +d58BXryl3mhgIVoNlZsF081B0jDIjVPfmb3PmTC6RWob5fZ9/g7AkL4HWVHdJnC5lExV9Au9zF8K +EM+cN47u9J7Yj/jopGQ5+L1DHEuGywO2qsJl9rHpbRNXMuxVXxV0IPck5YqfWUq47+ygs1XcnaVN +vM2c5kRg9vQwgybU78d6W1LknzvJSJNrq36B0vM2iWjgqVRiGiUmt783muphDxQE7yAIvv2pXmC3 +qgyWPxmWofMgljn/+jnjMqGngDGW0mxfZAPj7G7685kPJpnjaZQQl8ur31iAQraRhR0ZAPqDUzWO +fFS8IUEYY1TiOBuaRSXUT3ZwwZEHyed7vp93ti/aB8qUKvGr3e/qLBSSnt3Nu6uFQuDgoslBrihY +JIHPJ4Z9ufxMRfziyJgRQfJFMOuWI/EJvK/sKdPKPuwBEvQ06fte7r0w4MFw2/pMdfKJseoO1Or9 +UUDf4+5Xoe00DIpKqJ3zfUivWP5fx5i4d7hvOGiRA7meaX1dHDKRev4ED1Ioze0cvWfC0/EDSJ/K +VHvABJOV7G5HzWzeO+NzvnxSVyH2E7HsAXDQVP1E0S0vSbRBi2Tc1xOVG6mMmaEldX+1AIia4Y4R +R/2GgMF/H7viQEbnLPuW7TeNuWQurxdayq3JdIgdIm+pVrvTL9T+aY1crFv5j4H/WDfnjspuLhLB +fxg3D/8v1F2+xqA7GI397kLf5X07SI4mmU5j0hn0a6N/3DFsergs3J5n3DH3rGvc7QNImDfoDE/8 +mLz2BtC9uRh3Ev7/8LdrfwpzZ+D3axcTZjg+4mbCLCcL8E+EkSWY+tvFqIDBC//AHxfwyxc0/e0W +3JfupxfG3cSxblwCK4R5GcAWInyYkeDtntIWjUKTFGZFRnRjQ1SGhQnRaDgiwUwCy4Yl8prEhwWZ +4dxpl8AIAI0IDyUAxM3LXDjKiCym74UlkHxuXpLDgihIbkFkw5gCBC/xUT4s8xzMIQphjmOjbj4i +hAWOg8lEeCREeTfPywCaQF7jZFguvMaL4QjHRsjgcoQX3TwnhMWIDFBH5TDLsvAaQC1HRAXGKBPF +11gmLIk8QBAVwxLDYCc2zETgF5wtKokiaYmwLC6fA0AkjrzHwioFVmljYd2kJRpRWxgYkrREohHS +wsusSN7jw1wEloC4EWVWgLXwAGaUcwOCw7LMwS+48IgIA0RkpQXekyPhKC/xSi+JgUHZCKBHgl8Y +QIYgSWRT+EhUIDsHC5ZwC8jOMazSxghKJwkXjNsr46YubHja9QETQn+YXMGxBLvWI22wgIjSJsJa +sUXgcBBsEQSlJSLwSgP8z91wKZ0krRMfdSsDCbOBRPfidA0AggGiBsTBS6zyROQEAgduiCiSpoiE +hAQtUVmQlRYetxGIjYmyCiCw3wIBZLEXjiSpI0UlZaS56RCO/Vvrw0pOoXb4wiyMzA== + + + IPBw1sNSVMLjx8Ay4fBxoFlGOaQlwDEflWRcBeCdY4AKeUEGJCBa4HiJER7pmwHqwpPCSHBSgKp5 +IBegdDwgAg/HgQFUzdoKpC0aZZR+oN5ESFtEwSTPiGExipvEc2FJ4pAykT/g8YWJGFw/0iXP424J +XAQQF4XXWJhbJCQjKQTCs3Bco4h3eJ8nYEELJ/HaYQfCBrA4EagPVyHKYZ6JwuBwklgBtrvqQtqO +EMKOIow8TAa/8BKLrUDRPJwVNxx2RAgH/Tl8EoFfkUphRs7NCZEwnGGBkIyMZMVxeKwFmCfKAb9D +YIBvhCWYi8DAsTATxwHIwK2gJRoWETwOliUzEraIsE+AKGA2wEMAoQilIMEpbbhYOHiiRHYJUCVE +ZDcL7AQISybwwiRzLSpXSLv0Njie5GQADAycDQ4OmKmN4QSln8wqsDLALqMRmBMQIUUA8xwTBUaJ +x59H/gHUzOGGMQJH4OJkgKLhQvgJqQMWYbGMRFCj8KIIo5x2DqkCuQ3wpGiEENQicRacaL6QUoQf +iEIi+kKhtYThZNBr1CZUwlDrSiUMiSB0t1eQeVGNBQq6zIuqMk/WZF5ElXkozRSZx2kyT9RlXkST +eTyReYwm83hN5kU1mcfrMo/VZJ64IPMiZpnHW8g8XpN5vCrzJE6Tebwm80RN5gGlqTJPVmUesBmT +zIOWBZkHbSaZhy3zMg9bFmQeYy/zhAWZJ1rIPEGTeZIq81hGk3lRTeYJusyLajJPUGVeVJN5gibz +zBuuyDxWE0K8LvNYTebxmsxjNZnHazKPVWUer8s8VpN5vCbzWE3m8ZrMM083k3mSrAkhXpN50KTK +PF6VedCiSjNek2aMJvP4mcxb7EVGktSRiMwzT4dwIHELMuKMCYtAKeRI8ApDR/TLEYmwYCaCHB4o +CTghS/YxCoyHsHxRIrsv4mmRCOETKQBDANkCBxeEqCInkZNGeZacLAGJFV8DkmR5RCD8AhySMEMg +OlwdSC+ZQTICBHKIUiAaEJ4CvofgImkRlVKAo4AaUwQXIIiEJHG/kGgiynuyQtx4bkVCLNBL5nBX +NNkrgFgBquHIsiMRWSRwskyUSGNAU1RAARVl8OiwbgVxiE0CCse7F1CZduKvU3K2EYG8DP/KcHh6 +5NzKHApWra1gbOPIlhQMby62zN77mDXCsQ3LUZCehglmbQVjG6yPFwTDeJZNszfxPAE9inxkBgqS +kKjqBDPoDE36svQ3rdq0Vw1TzGAxzGGAz9CmL01/16pt9u4HbgfsuETYAHBYVuIJa4jgaRH1poJC +fUwkMtfGAwELeAIt20D3kGUydQQYRASp2dgGS5WRunhU6YncRtUZGR7IA4kTUFSwirAXBGQ/ArIR +hANoH4hRAJYbxbMCSiacF2xRtSzQhsKiGNUb4DXUcfA8651gX4Fjw2t8FMSOzJOWqERAEhXJogii +qBTR2mSi2oFKysC5I2/KyIyUNo5lUO+UUF7gaHjM4QVBANYuskaw1AZlNaAyCoZOEdT0WMJBQNuV +CVok5ABkwShj8DUQpiIyOgHPLbK+CAhTCTkPoJOXREXOwmAGpKM0jhJJqTaR/cLtZ/Dko/rECgo3 +4ySJJfsQAWUayBKMYxaPu9aCImzWBlwQeQiOBaQYlWW9DWQTTITyiVdsDpwO6JHVQUgrVpQEG2Ak +D2xTCVCWOA3QOTolupxiovCgFER5lKPA2qISCKqe0oaESJoiZAxg5qh5ALeXJNRCo7AIGeUbil8B +dWHoIQqMYWbEGlgZojxP5lFgqjzqOmAnSBLuN/RiCC9EWxwsFTJ6RJYUoSICTnAs2GdgOpLephyt +KIeUgnZTBAWKonQocImipFDB3IlMW59INLhA8qmWTZTQHEgunqwZ9CeOaNDA9QRiXcvAEQhUAop5 +BVeirFC0iDJc5MnuE9JWDrLEsaoNilZVgfAK2DhJkb6oguC7qABGFH4eYSKyijGOVfuBbCNNomrb +oZohs4Sjo56IG4jTMqgN4NaDwBNV8ECdB4YqabIJNxctzDuVBiReaSPDkhY8FKQFjaYFOklrigmA +LEWJwQ1oifJEakTQRANyB8qAHZIi2sZzgtrEaaudf1W1Mm5dstvndz/cu5ZJRK0Ph4pVhKiAcGii +HApGMKlQj5VUHQtW0HVxUcQpSm+wfjng5Ni28G4XQLCfkxMRUlRv0XCLAP5gLmAOIOUVNUqG0wHD +ossCSJNsBuEqXYtXHaYCWgazQVaEG2jvOFUE7UtBUa4icNpgWCBB0CZEhf3wUVzB4qvLp0IHGyIB +2WOEiaJURnKKsGhBC7hvcIi7hAswHE6vyZfu4qsOM0mCIkN5kEwysapwBKJO88hIQVTgqBJaNsjE +gXEC5ZAm05sOExGjAfmliLiAXcc1RZQTh2IRrG6ewI/ePpRT0I8YUV2Ldx3mQncZw0pEFBDeDHNx +ILPwBHBwGkEq4LCg0ooiylt4xrEcWejCq05TgeYqEi0BmJhEDhs6TDhUldHW59RhkVsSLhuBbZTJ +qsyvLp9J0WKRXDkZLVFclMAAGxNR7YU5QQ1EaJEfS0TX4HhFu+5avOswF4NMl0eGDxKDEYkvDHkC +4V3o5kEGDOPCwY2KaIKybDjKsjJpM7+7fC5V8KG4n3Nzwepwq2zcXBxrdnNxrIWbS1LdXGC8L7q5 +ZDRaoIlRLCvQQBn0I6C1CFsDz4jbDu0oBjUAaGPRVIA2dF2xqGihBSSJskpVHDrIOFSyZYJpURkL +IOTRT4WePEmUOGKBy+iDRGUClKYosbeiRNxBi4xuCvQIRlVvInE7RfQmlFUo/1nF18QSrzlxcxG5 +xqMfh7jQ0JeG+pQiHBUnEqpmcIyEKJxiOHthiZhbyAtZ0c0BouDoKwoFx0RY8hpOi2tDJicR4cYR +7xu6xMhxBV7LEZNZVjGM/jD8BRBEOBF6z2CkCApgVlaYUxQ9hRFeUxRgEjTUOaI/otNMa2kQlspw +6GDRezEKmnliVgLj5UBTg8MkkwWjcgrT8cAtRBRnuMXoSOOQIEgngB+1EHS+SRySmIiCNYLmKScg +USP5yKjqwUpA1spIh3iMiJwiXEIhSPTTRKNq2EB1jQqKaxTQBBhTXaOi5hrlFlyj7IJrFOYBjosi +JIKyBNaLvh5CKKCHMKiHKO5GpBiM4PACKuEcg5qbdkRBhSDvCSIwaWQGQjQCK4ZHgog8F6hRBnMD +34OHircJoBPRaObQlGAjCmcGUgYwQUkDBRNb0KJnRLKfwLii2AtMBOLcIr1ERlGcJVDHoQU1LZkl +DJUokQ0iNVEnBgg4Hl7n0HkUVZkzUfU5UMBFDq0HHmMVMiHfqKS4YdHyAWQAMmFfRRZ1woga2MGR +0Y0gYDhJIjhBB5PMysQw4XmwylAYK6cPNlpGjxi2sCKgXiAqJfH3gdmGDjTSiZFYluxcFIQPaYmi +3sfxsuo0gxYRthIxGVUIDJvIatHhSxyR8DqPh59DhiKyioEDFEe8wgxhqVGVj+PSgLMD61HCeBLL +oH8ZGST6LOAXYLKc+l6EHAwZNw5sKw4dh1FB8dezHJIOMk+Dnz1NfNBmP3tkwc8uWPjZuQU/u6h5 +1XnNzy7qPvWZn33WFtV96hHNzz7Xtuhn51H9jxBWSyw2JHJRwBYOYzLYMvOzEwPY7GfnzH52IAST +n52Z+dmB04DOAfwAqJwHBgK0gfChps6rMoSLomcN0QAnV0RhAu/JnGKdzNoKxjYeScDUJoaBAGA0 +9DAyRKRhZAnIEpQTVbrA+WAjxE7mkBGAugRtOABuGViRsEkCacHzzEWRVyBeAFCGJxYBgioJguIj +YDmF4cObPEGLwn70yAKrRBbSxrBX1Bz24qSFsBenh71ELezF6WEv0SLsFV0Ie0UWwl68Oeyl2DeE +wnlZIFSIZlhPDdDwircRaE5ws6CskOOD+8FEyYFCZiMpwWMiQ4AwgbEz6GuYtRWgDf2caCkAaxcx +ukDeRCUHcSZE8HDAoMSYAP01wiiEg5FscoCA3eHZQk4gS0TWgcxB1zOHngMGhS0GDnji58eRMPSA +yFdpGTkB8E0SH4oi3zGv1zHaaRX58RZr7VZlVOt0WyNXe1z7q+Wu9fuDSW3SGsITd3vUGk8Go5Z7 +/Dn4G1vgFa2715u9zrn+L1T7Dxc= + + + TM + \ No newline at end of file diff --git a/modules/manager2/webapp/index.html b/modules/manager2/webapp/index.html new file mode 100644 index 000000000000..a6112c8942d2 --- /dev/null +++ b/modules/manager2/webapp/index.html @@ -0,0 +1,63 @@ + + + + + + + + Tomcat Manager + + + + + +
+ + + + + diff --git a/modules/manager2/webapp/js/api.js b/modules/manager2/webapp/js/api.js new file mode 100644 index 000000000000..a32634ca8e04 --- /dev/null +++ b/modules/manager2/webapp/js/api.js @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +// Base path of the web application, derived from the location of this +// script so the webapp can be deployed under any context path. +// e.g. this file lives at {context}/js/api.js -> BASE = {context} +export const BASE = new URL('.', import.meta.url).pathname.replace(/js\/$/, '').replace(/\/$/, ''); + +let csrfToken = null; + +/** + * Perform a JSON API request. + * + * @param {string} method HTTP method + * @param {string} path API path, e.g. /api/apps + * @param {object|FormData} [body] JSON body or multipart data + * @returns {Promise} parsed JSON payload (or raw text for + * endpoints that return plain text) + */ +export async function api(method, path, body) { + const headers = { Accept: 'application/json' }; + + if (body instanceof FormData) { + // multipart: the browser sets the content type with the boundary + } else if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + if (method !== 'GET' && method !== 'HEAD') { + if (!csrfToken) { + throw new Error('CSRF token not available. Reload the page and try again.'); + } + headers['X-CSRF-Token'] = csrfToken; + } + + let payload; + if (body instanceof FormData) { + payload = body; + } else if (body !== undefined) { + payload = JSON.stringify(body); + } + + const resp = await fetch(BASE + path, { + method, + headers, + body: payload, + credentials: 'same-origin', + }); + + const token = resp.headers.get('X-CSRF-Token'); + if (token) { + csrfToken = token; + } + + const text = await resp.text(); + + // Unauthenticated: the container redirected to the login page, or the + // response is a 401. + if (resp.redirected || resp.status === 401) { + redirectToLogin(); + throw new Error('unauthenticated'); + } + + const type = (resp.headers.get('Content-Type') || '').toLowerCase(); + const isHtml = type.includes('text/html') + || /^\s* api('GET', path); + +let redirected = false; + +const BOUNCE_GUARD_MS = 8000; +const BOUNCE_GUARD_KEY = 'manager2.authBounce'; + +/** + * Send the browser to the login page while preserving the current page. + *

+ * A full navigation to the current URL is performed: the server gates every + * page (see HomeServlet) and shows the login page at that same URL, so a + * successful login returns the user to exactly the page they were on + * (instead of dropping them at the application root). + *

+ * A short guard against reload loops: if authentication keeps failing + * (e.g. bad credentials), the second bounce within the guard window is + * suppressed so the browser does not spin in a reload cycle. + */ +export function redirectToLogin() { + if (redirected) { + return; + } + redirected = true; + let last = 0; + try { + last = Number(window.sessionStorage.getItem(BOUNCE_GUARD_KEY)) || 0; + } catch (e) { + // sessionStorage unavailable: bounce once, guarded by 'redirected' + } + const now = Date.now(); + try { + window.sessionStorage.setItem(BOUNCE_GUARD_KEY, String(now)); + } catch (e) { + // ignore + } + if (now - last < BOUNCE_GUARD_MS) { + // Authentication failed again right after a bounce: the reload of the + // current page did not help. Fall back to the application root, which + // always renders the login page (a blank shell would otherwise leave + // the user without a way to sign in). + window.location.replace(BASE + '/'); + return; + } + window.location.assign(window.location.href); +} diff --git a/modules/manager2/webapp/js/charts.js b/modules/manager2/webapp/js/charts.js new file mode 100644 index 000000000000..f858fe28b135 --- /dev/null +++ b/modules/manager2/webapp/js/charts.js @@ -0,0 +1,260 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +// Small hand-written canvas charting: rolling line charts with multiple +// series and radial gauges. No dependencies. + +const PALETTE = [ + '#e7600c', '#2456a6', '#1a7f4b', '#9a3412', '#6d28d9', + '#0e7490', '#b45309', '#be185d', '#4d7c0f', '#475569', +]; + +function cssVar(name, fallback) { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +/** + * Multi-series rolling line chart. + * + * @param {HTMLCanvasElement} canvas + * @param {object} opts { series: [{name, color, unit}], windowMs (rolling + * window), formatValue(v) } + */ +export class LineChart { + constructor(canvas, opts) { + this.canvas = canvas; + this.series = opts.series; + this.windowMs = opts.windowMs || 5 * 60 * 1000; + this.formatValue = opts.formatValue || ((v) => String(v)); + this.points = new Map(); // series index -> [ [ts, value], ... ] + this.resizeObserver = new ResizeObserver(() => this.draw()); + this.resizeObserver.observe(canvas.parentElement || canvas); + this.draw(); + } + + /** + * Add a point to each series (values aligned with this.series). + */ + push(ts, values) { + values.forEach((v, i) => { + if (v === null || v === undefined || isNaN(v)) return; + if (!this.points.has(i)) this.points.set(i, []); + const arr = this.points.get(i); + arr.push([ts, v]); + }); + // Trim points outside the rolling window + const cutoff = ts - this.windowMs; + for (const arr of this.points.values()) { + while (arr.length > 0 && arr[0][0] < cutoff) { + arr.shift(); + } + } + this.draw(); + } + + /** + * Replace all points with the given entries (e.g. the full history window + * collected by the server). Each entry is `[ts, values]` where values are + * aligned with this.series; null/undefined values are skipped, like in + * {@link #push}. + * + * @param {Array<[number, (number|null)[]]>} entries + */ + setData(entries) { + const points = new Map(); + for (const [ts, values] of entries) { + values.forEach((v, i) => { + if (v === null || v === undefined || isNaN(v)) return; + if (!points.has(i)) points.set(i, []); + points.get(i).push([ts, v]); + }); + } + // Trim points outside the rolling window (the server normally already + // trims to its window; this keeps the chart correct if the window is + // reduced server-side). + const last = entries.length > 0 ? entries[entries.length - 1][0] : Date.now(); + const cutoff = last - this.windowMs; + for (const arr of points.values()) { + while (arr.length > 0 && arr[0][0] < cutoff) { + arr.shift(); + } + } + this.points = points; + this.draw(); + } + + clear() { + this.points.clear(); + this.draw(); + } + + draw() { + const canvas = this.canvas; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + if (canvas.width !== rect.width * dpr || canvas.height !== rect.height * dpr) { + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + } + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const w = rect.width; + const h = rect.height; + ctx.clearRect(0, 0, w, h); + + const padL = 46; + const padR = 8; + const padT = 8; + const padB = 20; + const plotW = w - padL - padR; + const plotH = h - padT - padB; + + // Determine time domain and value domain + let tMin = null; + let tMax = null; + let vMax = 0; + let vMin = 0; + for (const arr of this.points.values()) { + for (const [ts, v] of arr) { + if (tMin === null || ts < tMin) tMin = ts; + if (tMax === null || ts > tMax) tMax = ts; + if (v > vMax) vMax = v; + if (v < vMin) vMin = v; + } + } + if (tMin === null) { + tMin = Date.now() - this.windowMs; + tMax = Date.now(); + } + if (tMax - tMin < 1000) tMax = tMin + 1000; + if (vMax === vMin) vMax = vMin + 1; + vMax *= 1.08; // headroom + + const gridColor = cssVar('--border', '#ddd'); + const textColor = cssVar('--text-faint', '#888'); + + // Grid + y labels (4 divisions) + ctx.font = '11px ' + cssVar('--font', 'sans-serif'); + ctx.fillStyle = textColor; + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + const divisions = 4; + for (let i = 0; i <= divisions; i++) { + const v = vMin + (vMax - vMin) * i / divisions; + const y = padT + plotH - plotH * i / divisions; + ctx.beginPath(); + ctx.moveTo(padL, y); + ctx.lineTo(padL + plotW, y); + ctx.stroke(); + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(this.formatValue(v), padL - 6, y); + } + // x labels (3) + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let i = 0; i <= 2; i++) { + const ts = tMin + (tMax - tMin) * i / 2; + const x = padL + plotW * i / 2; + ctx.fillText(new Date(ts).toLocaleTimeString([], { hour12: false }), x, padT + plotH + 6); + } + + // Series + this.series.forEach((s, i) => { + const arr = this.points.get(i); + if (!arr || arr.length < 2) return; + ctx.beginPath(); + arr.forEach(([ts, v], j) => { + const x = padL + plotW * (ts - tMin) / (tMax - tMin); + const y = padT + plotH - plotH * (v - vMin) / (vMax - vMin); + if (j === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = s.color || PALETTE[i % PALETTE.length]; + ctx.lineWidth = 1.8; + ctx.lineJoin = 'round'; + ctx.stroke(); + }); + } +} + +/** + * Radial gauge. + * + * @param {HTMLCanvasElement} canvas + */ +export class Gauge { + constructor(canvas) { + this.canvas = canvas; + this.value = 0; + this.max = 1; + this.resizeObserver = new ResizeObserver(() => this.draw()); + this.resizeObserver.observe(canvas.parentElement || canvas); + } + + set(value, max) { + this.value = value; + this.max = max; + this.draw(); + } + + draw() { + const canvas = this.canvas; + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + if (canvas.width !== rect.width * dpr || canvas.height !== rect.height * dpr) { + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + } + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const w = rect.width; + const h = rect.height; + ctx.clearRect(0, 0, w, h); + + const cx = w / 2; + const cy = h - 6; + const r = Math.min(w / 2 - 8, h - 16); + const start = Math.PI; + const end = 2 * Math.PI; + const ratio = this.max > 0 ? Math.min(1, this.value / this.max) : 0; + + ctx.lineCap = 'round'; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, r, start, end); + ctx.strokeStyle = cssVar('--bg-inset', '#eee'); + ctx.stroke(); + + if (ratio > 0) { + const color = ratio > 0.9 ? cssVar('--danger', '#c00') + : ratio > 0.75 ? cssVar('--warn', '#a60') + : cssVar('--ok', '#1a7'); + ctx.beginPath(); + ctx.arc(cx, cy, r, start, start + (end - start) * ratio); + ctx.strokeStyle = color; + ctx.stroke(); + } + } +} + +export function palette(index) { + return PALETTE[index % PALETTE.length]; +} diff --git a/modules/manager2/webapp/js/logviewer.js b/modules/manager2/webapp/js/logviewer.js new file mode 100644 index 000000000000..e1d90fd0eb79 --- /dev/null +++ b/modules/manager2/webapp/js/logviewer.js @@ -0,0 +1,379 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +// Shared table + filter UI for the Logs and Access log pages. The set of +// filters that is shown depends on the fields the server reports for the +// selected file, so that only what the configured log format actually +// provides is filterable. + +import { api } from './api.js'; +import { el, clear, table, drawer, formatBytes, formatMs } from './ui.js'; + +// Column definitions per field name. `render` receives the row and returns a +// node or a string. +const COLUMNS = { + time: { label: 'Time' }, + level: { + label: 'Level', + render: (r) => levelBadge(r.level), + }, + thread: { label: 'Thread' }, + source: { label: 'Source' }, + message: { label: 'Message', wide: true }, + raw: { label: 'Line', wide: true }, + throwable: { label: 'Stack trace', wide: true }, + method: { label: 'Method' }, + host: { label: 'Host' }, + remoteAddr: { label: 'Remote addr' }, + localAddr: { label: 'Local addr' }, + localServerName: { label: 'Server' }, + user: { label: 'User' }, + logicalUserName: { label: 'User (identd)' }, + path: { label: 'Path' }, + query: { label: 'Query' }, + request: { label: 'Request', wide: true }, + protocol: { label: 'Protocol' }, + statusCode: { + label: 'Status', + numeric: true, + render: (r) => statusBadge(r.statusCode), + }, + size: { + label: 'Size', + numeric: true, + render: (r) => formatBytes(r.size), + }, + byteSentNC: { + label: 'Size (no C-L)', + numeric: true, + render: (r) => formatBytes(r.byteSentNC), + }, + elapsedTime: { + label: 'Elapsed', + numeric: true, + render: (r) => formatMs(r.elapsedTime), + }, + elapsedTimeS: { + label: 'Elapsed (s)', + numeric: true, + }, + firstByteTime: { + label: 'First byte', + numeric: true, + render: (r) => formatMs(r.firstByteTime), + }, + port: { label: 'Port', numeric: true }, + sessionId: { label: 'Session', mono: true }, + threadName: { label: 'Thread' }, + connectionStatus: { label: 'Connection' }, +}; + +function levelBadge(level) { + if (!level) return el('span', { class: 'muted' }, '-'); + const cls = + level === 'SEVERE' ? 'danger' + : level === 'WARNING' ? 'warn' + : level === 'INFO' ? 'info' + : 'plain'; + return el('span', { class: 'badge ' + cls }, level); +} + +function statusBadge(status) { + if (status === null || status === undefined || status === '') { + return el('span', { class: 'muted' }, '-'); + } + const n = Number(status); + if (Number.isNaN(n)) return String(status); + const cls = + n >= 500 ? 'danger' + : n >= 400 ? 'warn' + : n >= 300 ? 'info' + : 'ok'; + return el('span', { class: 'badge ' + cls }, String(status)); +} + +function labelFor(key) { + return (COLUMNS[key] && COLUMNS[key].label) || key; +} + +// Show all fields of a record in a drawer (full messages and stack traces). +function showRecord(row) { + const body = el('div', {}); + for (const [key, value] of Object.entries(row)) { + if (value === null || value === undefined) continue; + if (key === 'throwable' || key === 'raw' || key === 'message') { + body.append( + el('h3', { style: 'margin:14px 0 8px;' }, labelFor(key)), + el('pre', { class: 'block' }, Array.isArray(value) ? value.join('\n') : String(value))); + } else { + body.append(el('p', { style: 'margin:8px 0;' }, + el('strong', {}, labelFor(key) + ': '), + el('code', {}, typeof value === 'object' ? JSON.stringify(value) : String(value)))); + } + } + drawer({ title: 'Record', content: body }); +} + +// Build the column descriptors from the ordered field list the server sent. +function columnsFor(fields) { + const cols = []; + for (const key of fields) { + const def = COLUMNS[key] || { label: key }; + cols.push({ + key, + label: def.label || key, + numeric: def.numeric ? true : null, + muted: !def.render && !def.numeric ? true : null, + render: def.render + ? def.render + : def.mono + ? (r) => el('code', {}, r[key] === null || r[key] === undefined ? '-' : String(r[key])) + : null, + }); + } + return cols; +} + +function select(options, value, onChange) { + const node = el('select', {}, options.map((o) => + el('option', { value: o.value }, o.label))); + node.value = value; + node.addEventListener('change', () => onChange(node.value)); + return node; +} + +function textInput(placeholder, onInput, initial = '') { + const node = el('input', { type: 'text', placeholder, autocomplete: 'off' }); + node.value = initial; + let timer = 0; + node.addEventListener('input', () => { + clearTimeout(timer); + timer = setTimeout(() => onInput(node.value.trim()), 300); + }); + return node; +} + +function has(fields, name) { + return fields.includes(name); +} + +/** + * Render a log/access-log page. + * + * @param {object} container + * @param {object} opts { kind: 'log'|'access', title, subtitle, listUrl, + * fileUrl } + */ +export async function logPage(container, opts) { + const kind = opts.kind; + + const head = el('div', { class: 'page-head' }, + el('h1', {}, opts.title), + el('p', {}, opts.subtitle)); + container.append(head); + + const controls = el('div', { class: 'card log-controls' }); + const statusLine = el('div', { class: 'muted log-status', style: 'margin:0 2px 10px;' }); + const tableHolder = el('div', { class: 'card', style: 'margin-top:0;' }); + container.append(controls, statusLine, tableHolder); + + const state = { + files: [], + file: null, + lines: 500, + filters: {}, + fields: [], + }; + + let loading = false; + + // ---- controls ---- + + const fileSelect = el('select', {}); + const linesSelect = select( + [500, 1000, 2500, 5000].map((n) => ({ value: String(n), label: String(n) })), + '500', + (v) => { state.lines = Number(v); loadFile(); }); + + const refreshBtn = el('button', { type: 'button', class: 'btn btn-sm' }, + 'Refresh'); + refreshBtn.addEventListener('click', () => { loadList(); }); + + const filterHolder = el('div', { class: 'log-filters' }); + + const fileField = el('div', { class: 'field log-field' }, + el('label', {}, 'File'), fileSelect); + const linesField = el('div', { class: 'field log-field' }, + el('label', {}, 'Max lines'), linesSelect); + const refreshField = el('div', { class: 'field log-field log-field-btn' }, + el('label', {}, '\u00a0'), refreshBtn); + + fileSelect.addEventListener('change', () => { + state.file = fileSelect.value; + state.filters = {}; + loadFile(); + }); + + controls.append(fileField, linesField, filterHolder, refreshField); + + // Build the format dependent filters once we know the fields of the file. + // The current filter values are kept across reloads; they are only reset + // when the file changes. + function buildFilters(fields, meta) { + clear(filterHolder); + const f = state.filters; + if (fields.length === 0) return; + + if (kind === 'log') { + // Severity filter from the levels present in the file. + const levels = Object.keys(meta.levels || {}); + if (levels.length > 0) { + const options = [{ value: '', label: 'All severities' }] + .concat(levels.map((l) => ({ value: l, label: l }))); + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, 'Severity'), + select(options, f.level || '', (v) => { state.filters.level = v; loadFile(); }))); + } + } else { + if (has(fields, 'method') || has(fields, 'request')) { + const methods = (meta.methods || []).map((m) => m.name); + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, 'Method'), + select([{ value: '', label: 'All' }].concat(methods.map((m) => ({ value: m, label: m }))), + f.method || '', (v) => { state.filters.method = v; loadFile(); }))); + } + if (has(fields, 'statusCode')) { + const cls = ['1xx', '2xx', '3xx', '4xx', '5xx']; + const counts = meta.statusClasses || {}; + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, 'Status'), + select( + [{ value: '', label: 'All' }].concat(cls.map((c) => ({ + value: c, + label: c + (counts[c] ? ' (' + counts[c] + ')' : ''), + }))), + f.status || '', (v) => { state.filters.status = v; loadFile(); }))); + } + if (has(fields, 'user') || has(fields, 'logicalUserName')) { + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, 'User'), + textInput('Filter by user', (v) => { state.filters.user = v; loadFile(); }, f.user || ''))); + } + if (has(fields, 'sessionId')) { + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, 'Session ID'), + textInput('Filter by session ID', (v) => { state.filters.session = v; loadFile(); }, f.session || ''))); + } + } + // Free text search is available for both kinds. + filterHolder.append(el('div', { class: 'field log-field log-field-search' }, + el('label', {}, 'Search'), + textInput('Search', (v) => { state.filters.search = v; loadFile(); }, f.search || ''))); + } + + // ---- loading ---- + + async function loadList() { + let data; + try { + data = await api('GET', opts.listUrl); + } catch (err) { + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, err.message)); + return; + } + state.files = data.logs || []; + clear(fileSelect); + if (state.files.length === 0) { + fileSelect.append(el('option', { value: '' }, 'No log files found')); + fileSelect.disabled = true; + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, + 'No ' + (kind === 'log' ? 'log' : 'access log') + ' files were found in the logs directory.')); + statusLine.textContent = ''; + return; + } + fileSelect.disabled = false; + for (const f of state.files) { + const label = f.name + (f.format ? ' (' + f.format + ')' : ''); + fileSelect.append(el('option', { value: f.name }, label)); + } + // Keep the selection if it still exists, otherwise the most recent. + if (!state.files.some((f) => f.name === state.file)) { + state.file = state.files[0].name; + } + fileSelect.value = state.file; + loadFile(); + } + + async function loadFile() { + if (!state.file || loading) return; + loading = true; + clear(tableHolder); + tableHolder.append(el('div', { class: 'spinner', role: 'status', 'aria-label': 'Loading' })); + + const params = new URLSearchParams(); + params.set('name', state.file); + params.set('lines', String(state.lines)); + for (const [k, v] of Object.entries(state.filters)) { + if (v) params.set(k, v); + } + + let data; + try { + data = await api('GET', opts.fileUrl + '?' + params.toString()); + } catch (err) { + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, err.message)); + loading = false; + return; + } + + state.fields = data.fields || []; + buildFilters(state.fields, data); + + const records = data.records || []; + let summary = 'Showing ' + records.length + ' of ' + data.matched + + ' matching line' + (data.matched === 1 ? '' : 's') + + ' (total ' + data.total + ')'; + if (data.truncated) { + summary += ' - file truncated to the last ' + Math.round((data.readBytes / 1024 / 1024) * 10) / 10 + ' MiB'; + } + statusLine.textContent = summary; + + const cols = columnsFor(state.fields); + if (records.length === 0) { + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, + 'No lines match the current filters.')); + } else { + clear(tableHolder); + const t = table({ + columns: cols, + rows: records, + onRowClick: (row) => showRecord(row), + empty: 'No lines match the current filters.', + }); + t.classList.add('log-table'); + tableHolder.append(t); + } + loading = false; + } + + await loadList(); + return null; +} diff --git a/modules/manager2/webapp/js/main.js b/modules/manager2/webapp/js/main.js new file mode 100644 index 000000000000..0617135318be --- /dev/null +++ b/modules/manager2/webapp/js/main.js @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { BASE, get } from './api.js'; +import { register, setNotFound, render, setNavUpdater } from './router.js'; +import { el, clear, svgPath, toast, formatDuration } from './ui.js'; +import { dashboard } from './pages/dashboard.js'; +import { apps, appDetail } from './pages/apps.js'; +import { hosts } from './pages/hosts.js'; +import { monitoring } from './pages/monitoring.js'; +import { diagnostics } from './pages/diagnostics.js'; +import { logs } from './pages/logs.js'; +import { accessLog } from './pages/accesslog.js'; +import { users } from './pages/users.js'; +import { configuration } from './pages/configuration.js'; + +const NAV_ITEMS = [ + { route: '/', icon: 'dashboard', label: 'Dashboard' }, + { route: '/apps', icon: 'apps', label: 'Applications' }, + { route: '/hosts', icon: 'hosts', label: 'Hosts' }, + { route: '/configuration', icon: 'config', label: 'Configuration' }, + { route: '/users', icon: 'users', label: 'Users' }, + { route: '/monitoring', icon: 'monitoring', label: 'Monitoring' }, + { route: '/diagnostics', icon: 'diagnostics', label: 'Diagnostics' }, + { route: '/logs', icon: 'logs', label: 'Logs' }, + { route: '/access-log', icon: 'access-log', label: 'Access log' }, +]; + +let currentCleanup = null; +let serverInfo = null; + +// ============================ Theme ==================================== + +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + updateThemeIcon(); +} + +function currentTheme() { + return document.documentElement.getAttribute('data-theme') || 'auto'; +} + +function updateThemeIcon() { + const btn = document.getElementById('theme-toggle'); + const theme = currentTheme(); + const dark = theme === 'dark' + || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); + btn.innerHTML = ''; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '18'); + svg.setAttribute('height', '18'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', dark ? svgPath('sun') : svgPath('moon')); + svg.append(path); + btn.append(svg); +} + +function initTheme() { + const stored = localStorage.getItem('manager2.theme'); + applyTheme(stored || 'auto'); + document.getElementById('theme-toggle').addEventListener('click', () => { + const dark = document.documentElement.getAttribute('data-theme') === 'dark' + || (document.documentElement.getAttribute('data-theme') !== 'light' + && window.matchMedia('(prefers-color-scheme: dark)').matches); + const next = dark ? 'light' : 'dark'; + localStorage.setItem('manager2.theme', next); + applyTheme(next); + }); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeIcon); +} + +// ============================ Navigation =============================== + +function isActive(route, path) { + if (route === '/') return path === '/'; + return path === route || path.startsWith(route + '/'); +} + +function buildNav() { + const nav = document.querySelector('.sidenav'); + for (const item of NAV_ITEMS) { + const btn = nav.querySelector('[data-nav="' + item.route + '"]'); + btn.innerHTML = ''; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '18'); + svg.setAttribute('height', '18'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', svgPath(item.icon)); + svg.append(path); + btn.append(svg, document.createTextNode(item.label)); + btn.addEventListener('click', () => { + window.history.pushState({}, '', BASE + item.route); + render(); + }); + } +} + +function updateNav(path) { + document.querySelectorAll('.nav-item').forEach((btn) => { + const route = btn.dataset.nav; + btn.classList.toggle('active', isActive(route, path)); + }); +} + +// ============================ Routing ================================== + +function pageRoute(pattern, handler) { + register(pattern, async (params, path) => { + if (currentCleanup) { + currentCleanup(); + currentCleanup = null; + } + const container = document.getElementById('view'); + clear(container); + container.append(el('div', { class: 'spinner', role: 'status', 'aria-label': 'Loading' })); + try { + clear(container); + const cleanup = await handler(container, params, path); + if (typeof cleanup === 'function') { + currentCleanup = cleanup; + } + } catch (err) { + clear(container); + container.append(el('div', { class: 'card' }, + el('h3', {}, 'Something went wrong'), + el('p', { style: 'color:var(--text-soft);' }, err.message))); + } + container.focus({ preventScroll: true }); + }); +} + +// ============================ Boot ===================================== + +async function boot() { + initTheme(); + buildNav(); + setNavUpdater(updateNav); + + document.getElementById('logout').addEventListener('click', async () => { + try { + await fetch(BASE + '/logout', { method: 'POST', credentials: 'same-origin' }); + } catch (e) { + // ignore; the redirect below ends the session on the server anyway + } + window.location.href = BASE + '/'; + }); + + pageRoute('/', dashboard); + pageRoute('/apps', apps); + pageRoute('/apps/{host}/{path}', appDetail); + pageRoute('/hosts', hosts); + pageRoute('/configuration', configuration); + pageRoute('/monitoring', monitoring); + pageRoute('/diagnostics', diagnostics); + pageRoute('/logs', logs); + pageRoute('/access-log', accessLog); + pageRoute('/users', users); + + setNotFound((path) => { + const container = document.getElementById('view'); + clear(container); + container.append(el('div', { class: 'card empty' }, + el('p', {}, 'Not found: ' + path))); + }); + + try { + serverInfo = await get('/api/info'); + } catch (err) { + // api() already redirected to the login page on 401/redirect + return; + } + + const version = document.getElementById('server-version'); + const status = document.getElementById('server-status'); + if (serverInfo.server && serverInfo.server.info) { + version.textContent = serverInfo.server.info.replace(/^Apache Tomcat /, ''); + version.title = serverInfo.server.info; + } + if (serverInfo.runtime && serverInfo.host) { + status.append( + el('span', {}, serverInfo.host.name), + el('span', { style: 'color:var(--text-faint);' }, '·'), + el('span', {}, 'up ' + formatDuration(serverInfo.runtime.uptimeMs))); + } + + document.getElementById('app').hidden = false; + await render(); +} + +boot(); diff --git a/modules/manager2/webapp/js/pages/accesslog.js b/modules/manager2/webapp/js/pages/accesslog.js new file mode 100644 index 000000000000..e6ed60671425 --- /dev/null +++ b/modules/manager2/webapp/js/pages/accesslog.js @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { logPage } from '../logviewer.js'; + +export async function accessLog(container) { + return logPage(container, { + kind: 'access', + title: 'Access log', + subtitle: 'Access log files. The available filters (method, status, user, session ID) depend on the configured log format.', + listUrl: '/api/access-log', + fileUrl: '/api/access-log/file', + }); +} diff --git a/modules/manager2/webapp/js/pages/apps.js b/modules/manager2/webapp/js/pages/apps.js new file mode 100644 index 000000000000..1b33ff1f70a7 --- /dev/null +++ b/modules/manager2/webapp/js/pages/apps.js @@ -0,0 +1,687 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { BASE, api, getCsrfToken, setCsrfToken } from '../api.js'; +import { el, clear, table, stateBadge, toast, modal, confirm, drawer, + formatTimestamp, formatDuration } from '../ui.js'; + +/** + * The segment used in URLs to refer to a context path. + * "" (ROOT) becomes "root"; "/docs" becomes "docs". + */ +function toSegment(contextPath) { + return contextPath === '' ? 'root' : encodeURIComponent(contextPath.replace(/^\//, '').replace(/\/$/, '')); +} + +function fromSegment(segment) { + const s = decodeURIComponent(segment); + return s === 'root' ? '' : '/' + s; +} + +function appUrl(host, contextPath) { + return '/apps/' + host + '/' + toSegment(contextPath); +} + +// ============================ Apps list ================================ + +export async function apps(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, 'Applications'), + el('p', {}, 'Deploy, start, stop, reload and undeploy web applications.'), + el('span', { style: 'flex:1' }), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => deployModal() }, + 'Deploy application'))); + container.append(view); + + const wrap = el('div', { class: 'card' }); + view.append(wrap); + + async function load() { + let data; + try { + data = await api('GET', '/api/apps'); + } catch (err) { + wrap.append(el('div', { class: 'empty' }, err.message)); + return; + } + clear(wrap); + wrap.append(table({ + columns: [ + { + key: 'path', label: 'Path', + render: (a) => el('a', { + href: BASE + appUrl(a.host, a.path), + onclick: (e) => { + e.preventDefault(); + window.history.pushState({}, '', BASE + appUrl(a.host, a.path)); + window.dispatchEvent(new PopStateEvent('popstate')); + }, + }, a.path === '' ? '/' : a.path), + }, + { key: 'displayName', label: 'Display name', muted: true }, + { key: 'version', label: 'Version', muted: true, render: (a) => a.version || '-' }, + { key: 'state', label: 'State', render: (a) => stateBadge(a.available ? 'RUNNABLE' : 'STOPPED') }, + { key: 'sessions', label: 'Sessions', numeric: true }, + { + key: 'docBase', label: 'Doc base', muted: true, + render: (a) => el('code', {}, a.docBase || '-'), + }, + { + key: 'actions', label: 'Actions', + render: (a) => el('div', { class: 'row-actions' }, + a.available + ? el('button', { + type: 'button', class: 'btn btn-sm', + onclick: (e) => { e.stopPropagation(); lifecycle(a, 'stop'); }, + }, 'Stop') + : el('button', { + type: 'button', class: 'btn btn-sm btn-primary', + onclick: (e) => { e.stopPropagation(); lifecycle(a, 'start'); }, + }, 'Start'), + el('button', { + type: 'button', class: 'btn btn-sm', disabled: !a.available, + onclick: (e) => { e.stopPropagation(); lifecycle(a, 'reload'); }, + }, 'Reload'), + el('button', { + type: 'button', class: 'btn btn-sm btn-danger', disabled: a.self, + title: a.self ? 'Cannot undeploy the manager itself' : 'Undeploy', + onclick: (e) => { e.stopPropagation(); undeploy(a); }, + }, 'Undeploy')), + }], + rows: data.apps, + onRowClick: (a) => { + window.history.pushState({}, '', BASE + appUrl(a.host, a.path)); + window.dispatchEvent(new PopStateEvent('popstate')); + }, + empty: 'No applications deployed', + })); + } + + await load(); + return null; +} + +async function lifecycle(app, action) { + const ok = action === 'start' + ? true + : await confirm({ + title: action === 'stop' ? 'Stop application' : 'Reload application', + message: 'Stop or reload ' + (app.path === '' ? '/' : app.path) + '?', + confirmLabel: action === 'stop' ? 'Stop' : 'Reload', + danger: false, + }); + if (!ok) return; + try { + const res = await api('POST', '/api/apps/' + toSegment(app.path) + '/' + action + + '?path=' + encodeURIComponent(app.path) + '&version=' + encodeURIComponent(app.version || '')); + toast(res.message, 'ok'); + window.dispatchEvent(new PopStateEvent('popstate')); + } catch (err) { + toast(err.message, 'error'); + } +} + +async function undeploy(app) { + const ok = await confirm({ + title: 'Undeploy application', + message: 'Undeploy ' + (app.path === '' ? '/' : app.path) + '? The deployed files are kept on disk.', + confirmLabel: 'Undeploy', + danger: true, + requireText: app.path === '' ? '/' : app.path, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/apps/' + toSegment(app.path) + + '?path=' + encodeURIComponent(app.path) + '&version=' + encodeURIComponent(app.version || '')); + toast(res.message, 'ok'); + window.dispatchEvent(new PopStateEvent('popstate')); + } catch (err) { + toast(err.message, 'error'); + } +} + +// ============================ Deploy modal ============================= + +function deployModal() { + let tab = 'upload'; + + const uploadPane = el('div', {}, + el('div', { class: 'field' }, + el('label', {}, 'WAR file'), + el('input', { type: 'file', accept: '.war', id: 'deploy-war' })), + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, + el('label', {}, 'Context path (optional)'), + el('input', { type: 'text', id: 'deploy-path', placeholder: '/myapp' }), + el('span', { class: 'hint' }, 'Defaults to the WAR file name.')), + el('div', { class: 'field' }, + el('label', {}, 'Version (optional)'), + el('input', { type: 'text', id: 'deploy-version' })))); + + const serverPane = el('div', { style: 'display:none' }, + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, + el('label', {}, 'Context path'), + el('input', { type: 'text', id: 'srv-path', placeholder: '/myapp' })), + el('div', { class: 'field' }, + el('label', {}, 'Version (optional)'), + el('input', { type: 'text', id: 'srv-version' })), + el('div', { class: 'field span-2' }, + el('label', {}, 'XML configuration (optional)'), + el('input', { type: 'text', id: 'srv-config', placeholder: 'http://.../context.xml' })), + el('div', { class: 'field span-2' }, + el('label', {}, 'WAR location (optional)'), + el('input', { type: 'text', id: 'srv-war', placeholder: 'file:///.../myapp.war' })), + el('div', { class: 'field span-2' }, + el('label', {}, ''), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'srv-replace' }), + 'Replace an existing deployment')))); + + const tabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active', onclick: () => switchTab('upload') }, 'Upload'), + el('button', { type: 'button', class: 'tab', onclick: () => switchTab('server') }, 'From server')); + + function switchTab(name) { + tab = name; + tabs.querySelectorAll('.tab').forEach((t, i) => { + t.classList.toggle('active', (i === 0) === (name === 'upload')); + }); + uploadPane.style.display = name === 'upload' ? '' : 'none'; + serverPane.style.display = name === 'server' ? '' : 'none'; + } + + const progress = el('div', { class: 'progress', style: 'display:none' }, el('div')); + let busy = false; + + const close = modal({ + title: 'Deploy application', + wide: true, + content: el('div', {}, + tabs, + uploadPane, + serverPane, + progress), + actions: [ + { label: 'Cancel' }, + { + label: 'Deploy', + class: 'btn-primary', + onClick: async (c) => { + if (busy) return; + busy = true; + try { + let message; + if (tab === 'upload') { + const fileInput = document.getElementById('deploy-war'); + const file = fileInput.files[0]; + if (!file) { + toast('Select a WAR file first.', 'warn'); + return; + } + const fd = new FormData(); + fd.append('war', file); + const pathValue = document.getElementById('deploy-path').value.trim(); + if (pathValue) fd.append('path', pathValue); + const version = document.getElementById('deploy-version').value.trim(); + if (version) fd.append('version', version); + progress.style.display = ''; + progress.firstChild.style.width = '0%'; + message = await uploadWithProgress('/api/apps/upload', fd, (p) => { + progress.firstChild.style.width = p + '%'; + }); + } else { + const body = {}; + const pathValue = document.getElementById('srv-path').value.trim(); + if (pathValue) body.path = pathValue; + const version = document.getElementById('srv-version').value.trim(); + if (version) body.version = version; + const config = document.getElementById('srv-config').value.trim(); + if (config) body.config = config; + const war = document.getElementById('srv-war').value.trim(); + if (war) body.war = war; + if (document.getElementById('srv-replace').checked) body.replace = true; + const res = await api('POST', '/api/apps/deploy', body); + message = res.message; + } + toast(message, 'ok'); + c(); + window.dispatchEvent(new PopStateEvent('popstate')); + } catch (err) { + toast(err.message, 'error'); + } finally { + busy = false; + progress.style.display = 'none'; + } + }, + }, + ], + }); +} + +function uploadWithProgress(path, formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', BASE + path); + xhr.withCredentials = true; + xhr.setRequestHeader('X-CSRF-Token', getCsrfToken()); + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + onProgress(Math.round(e.loaded / e.total * 100)); + } + }); + xhr.addEventListener('load', () => { + const token = xhr.getResponseHeader('X-CSRF-Token'); + if (token) setCsrfToken(token); + let data = null; + try { + data = JSON.parse(xhr.responseText); + } catch (e) { + // not JSON + } + if (xhr.status >= 200 && xhr.status < 300) { + resolve(data ? data.message : xhr.responseText); + } else { + const err = new Error((data && data.message) || ('Upload failed with status ' + xhr.status)); + err.status = xhr.status; + reject(err); + } + }); + xhr.addEventListener('error', () => reject(new Error('Upload failed (network error)'))); + xhr.send(formData); + }); +} + +// ============================ App detail =============================== + +export async function appDetail(container, params) { + const host = params.host; + const contextPath = fromSegment(params.path); + const seg = toSegment(contextPath); + const query = '?path=' + encodeURIComponent(contextPath); + + const view = el('div', {}, + el('div', { class: 'breadcrumb' }, + el('a', { href: BASE + '/apps', onclick: (e) => { + e.preventDefault(); + window.history.pushState({}, '', BASE + '/apps'); + window.dispatchEvent(new PopStateEvent('popstate')); + } }, 'Applications'), + ' / ', + document.createTextNode(contextPath === '' ? '/' : contextPath))); + + const head = el('div', { class: 'page-head' }, + el('h1', { id: 'app-title' }, contextPath === '' ? '/' : contextPath), + el('span', { id: 'app-state' })); + view.append(head); + + const tabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active' }, 'Overview'), + el('button', { type: 'button', class: 'tab' }, 'Sessions'), + el('button', { type: 'button', class: 'tab' }, 'Metrics')); + const panes = el('div', {}, + el('div', { class: 'pane' }, el('div', { class: 'spinner' })), + el('div', { class: 'pane', style: 'display:none' }), + el('div', { class: 'pane', style: 'display:none' })); + view.append(tabs, panes); + container.append(view); + + const tabButtons = tabs.querySelectorAll('.tab'); + const paneNodes = panes.querySelectorAll('.pane'); + let loaded = [false, false, false]; + + function switchTab(index) { + tabButtons.forEach((t, i) => t.classList.toggle('active', i === index)); + paneNodes.forEach((p, i) => { p.style.display = i === index ? '' : 'none'; }); + if (!loaded[index]) { + loaded[index] = true; + if (index === 0) loadOverview(); + else if (index === 1) loadSessions(); + else loadMetrics(); + } + } + tabButtons.forEach((t, i) => t.addEventListener('click', () => switchTab(i))); + + // ---------------- Overview ---------------- + let appInfo = null; + async function loadOverview() { + const pane = paneNodes[0]; + clear(pane); + let data; + try { + data = await api('GET', '/api/apps' + query); + } catch (err) { + pane.append(el('div', { class: 'empty' }, err.message)); + return; + } + const app = data.apps.find((a) => a.host === host && a.path === contextPath); + if (!app) { + pane.append(el('div', { class: 'empty' }, 'Application not found.')); + return; + } + appInfo = app; + + const available = app.available; + const stateEl = document.getElementById('app-state'); + clear(stateEl); + stateEl.append(stateBadge(available ? 'RUNNABLE' : 'STOPPED')); + + const actions = el('div', { class: 'row-actions', style: 'margin-bottom:16px;' }, + available + ? el('button', { type: 'button', class: 'btn', onclick: () => lifecycle(app, 'stop') }, 'Stop') + : el('button', { type: 'button', class: 'btn btn-primary', onclick: () => lifecycle(app, 'start') }, 'Start'), + el('button', { type: 'button', class: 'btn', disabled: !available, onclick: () => lifecycle(app, 'reload') }, 'Reload'), + el('button', { + type: 'button', class: 'btn btn-danger', disabled: app.self, + title: app.self ? 'Cannot undeploy the manager itself' : 'Undeploy', + onclick: () => undeploy(app), + }, 'Undeploy')); + + const dl = el('dl', { class: 'kv' }, + kv('Host', host), + kv('Path', contextPath === '' ? '/' : contextPath), + kv('Display name', app.displayName || '-'), + kv('Version', app.version || '-'), + kv('Doc base', el('code', {}, app.docBase || '-')), + kv('Session timeout', app.sessionTimeout != null ? app.sessionTimeout + ' min' : '-'), + kv('Active sessions', String(app.sessions))); + + pane.append(el('div', { class: 'card' }, + el('h3', {}, 'Details'), + actions, + dl)); + } + + function kv(label, value) { + return [ + el('dt', {}, label), + el('dd', {}, typeof value === 'object' ? value : document.createTextNode(String(value))), + ]; + } + + // ---------------- Sessions ---------------- + async function loadSessions() { + const pane = paneNodes[1]; + clear(pane); + + const controls = el('div', { class: 'row-actions', style: 'margin-bottom:12px;' }, + el('input', { + type: 'number', id: 'expire-idle', min: '0', placeholder: 'idle seconds', + style: 'width:130px;', + }), + el('button', { + type: 'button', class: 'btn btn-sm', + onclick: async () => { + const idle = parseInt(document.getElementById('expire-idle').value, 10); + if (isNaN(idle) || idle < 0) { + toast('Enter an idle timeout in seconds.', 'warn'); + return; + } + try { + const res = await api('POST', '/api/apps/' + seg + '/expire' + query, { idle }); + toast(res.message, 'ok'); + loadSessions(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, 'Expire idle'), + el('span', { style: 'flex:1' }), + el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + onclick: async () => { + const selected = Array.from(pane.querySelectorAll('input.session-check:checked')) + .map((c) => c.dataset.id); + if (selected.length === 0) { + toast('Select sessions to invalidate.', 'warn'); + return; + } + const ok = await confirm({ + title: 'Invalidate sessions', + message: 'Invalidate ' + selected.length + ' session(s)?', + confirmLabel: 'Invalidate', + danger: true, + }); + if (!ok) return; + try { + const res = await api('POST', '/api/apps/' + seg + '/sessions/invalidate' + query, { ids: selected }); + toast(res.message, 'ok'); + loadSessions(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, 'Invalidate selected')); + pane.append(controls, el('div', { id: 'sessions-table' })); + + let sort = 'lastAccessedTime'; + let asc = false; + await loadSessionTable(sort, asc); + } + + async function loadSessionTable(sortKey, sortAsc) { + const holder = document.getElementById('sessions-table'); + let url = '/api/apps/' + seg + '/sessions' + query; + if (sortKey) { + url += '&sort=' + encodeURIComponent(sortKey) + '&order=' + (sortAsc ? 'ASC' : 'DESC'); + } + let data; + try { + data = await api('GET', url); + } catch (err) { + clear(holder); + holder.append(el('div', { class: 'empty' }, err.message)); + return; + } + clear(holder); + holder.append(table({ + columns: [ + { + key: 'check', label: '', + render: (s) => el('input', { + type: 'checkbox', class: 'session-check', 'data-id': s.id, + style: 'accent-color:var(--accent);', + }), + }, + { + key: 'id', label: 'Id', sortable: true, + render: (s) => el('code', {}, s.id), + }, + { + key: 'user', label: 'User', sortable: true, + render: (s) => s.user || '-', + }, + { + key: 'creationTime', label: 'Created', sortable: true, + render: (s) => formatTimestamp(s.creationTime), + }, + { + key: 'lastAccessedTime', label: 'Last accessed', sortable: true, + render: (s) => formatTimestamp(s.lastAccessedTime), + }, + { + key: 'maxInactiveInterval', label: 'Timeout (s)', sortable: true, numeric: true, + }, + { + key: 'active', label: 'State', + render: (s) => el('span', { class: 'badge ' + (s.active ? 'ok' : 'stop') }, + s.active ? 'active' : 'proxy'), + }, + ], + rows: data.sessions, + sortKey: data.sort || sortKey, + sortAsc: data.order === 'ASC', + onSort: (key) => { + if (sortKey === key) { + asc = !asc; + } else { + sortKey = key; + asc = true; + } + loadSessionTable(sortKey, asc); + }, + onRowClick: (s) => sessionDrawer(s), + empty: 'No sessions', + })); + } + + function sessionDrawer(session) { + const body = el('div', {}, + el('dl', { class: 'kv', style: 'margin-bottom:20px;' }, + el('dt', {}, 'Id'), el('dd', {}, el('code', {}, session.id)), + el('dt', {}, 'User'), el('dd', {}, session.user || '-'), + el('dt', {}, 'Locale'), el('dd', {}, session.locale || '-'), + el('dt', {}, 'Created'), el('dd', {}, formatTimestamp(session.creationTime)), + el('dt', {}, 'Last accessed'), el('dd', {}, formatTimestamp(session.lastAccessedTime)), + el('dt', {}, 'Timeout'), el('dd', {}, session.maxInactiveInterval + ' s'))); + + const actions = el('div', { class: 'row-actions', style: 'margin-bottom:16px;' }, + el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + onclick: async () => { + const ok = await confirm({ + title: 'Invalidate session', + message: 'Invalidate session ' + session.id + '?', + confirmLabel: 'Invalidate', + danger: true, + }); + if (!ok) return; + try { + const res = await api('POST', '/api/apps/' + seg + '/sessions/invalidate' + query, + { ids: [session.id] }); + toast(res.message, 'ok'); + close(); + loadSessions(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, 'Invalidate session')); + body.append(actions, el('h3', {}, 'Attributes'), el('div', { id: 'attr-holder' })); + + const close = drawer({ title: 'Session ' + session.id, content: body }); + + api('GET', '/api/apps/' + seg + '/sessions/' + encodeURIComponent(session.id) + query) + .then((detail) => { + const holder = body.querySelector('#attr-holder'); + clear(holder); + if (!detail.attributes || detail.attributes.length === 0) { + holder.append(el('div', { class: 'empty' }, 'No attributes')); + return; + } + holder.append(el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Name'), + el('th', {}, 'Class'), + el('th', {}, 'Value'), + el('th', {}))), + el('tbody', {}, detail.attributes.map((a) => el('tr', {}, + el('td', {}, el('code', {}, a.name)), + el('td', { class: 'muted' }, el('code', {}, a.class || '-')), + el('td', {}, el('code', {}, a.value || '-')), + el('td', {}, + el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + onclick: async () => { + const ok = await confirm({ + title: 'Remove attribute', + message: 'Remove attribute ' + a.name + '?', + confirmLabel: 'Remove', + danger: true, + }); + if (!ok) return; + try { + await api('DELETE', '/api/apps/' + seg + '/sessions/' + + encodeURIComponent(session.id) + '/attributes/' + + encodeURIComponent(a.name) + query); + toast('Attribute removed.', 'ok'); + close(); + loadSessions(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, 'Remove')))))))); + }) + .catch((err) => { + const holder = body.querySelector('#attr-holder'); + clear(holder); + holder.append(el('div', { class: 'empty' }, err.message)); + }); + } + + // ---------------- Metrics ---------------- + async function loadMetrics() { + const pane = paneNodes[2]; + clear(pane); + let data; + try { + data = await api('GET', '/api/status/apps/' + seg + '/' + host + query); + } catch (err) { + pane.append(el('div', { class: 'empty' }, err.message)); + return; + } + + const m = data.manager || {}; + pane.append(el('div', { class: 'grid kpis' }, + kpi('Active sessions', m.activeSessions != null ? String(m.activeSessions) : '-'), + kpi('Expired sessions', m.expiredSessions != null ? String(m.expiredSessions) : '-'), + kpi('Avg session lifetime', m.sessionAverageAliveTime != null ? + formatDuration(m.sessionAverageAliveTime) : '-'), + kpi('Max session life', m.sessionMaxAliveTime != null ? + formatDuration(m.sessionMaxAliveTime) : '-'))); + + if (data.jsp) { + pane.append(el('div', { class: 'card' }, + el('h3', {}, 'JSPs'), + el('dl', { class: 'kv' }, + el('dt', {}, 'JSP files'), el('dd', {}, String(data.jsp.jspCount)), + el('dt', {}, 'Reloads'), el('dd', {}, String(data.jsp.jspReloadCount))))); + } + + pane.append(el('div', { class: 'card' }, + el('h3', {}, 'Servlets'), + el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Name'), + el('th', {}, 'Mappings'), + el('th', { class: 'num' }, 'Requests'), + el('th', { class: 'num' }, 'Errors'), + el('th', { class: 'num' }, 'Processing time'), + el('th', { class: 'num' }, 'Max time'))), + el('tbody', {}, (data.wrappers || []).map((w) => el('tr', {}, + el('td', {}, el('code', {}, w.name)), + el('td', { class: 'muted' }, (w.mappings || []).join(', ')), + el('td', { class: 'num' }, String(w.requestCount)), + el('td', { class: 'num' }, String(w.errorCount)), + el('td', { class: 'num' }, formatDuration(w.processingTime)), + el('td', { class: 'num' }, formatDuration(w.maxTime))))))))); + } + + function kpi(label, value) { + return el('div', { class: 'card kpi' }, + el('span', { class: 'kpi-label' }, label), + el('span', { class: 'kpi-value' }, value)); + } + + loaded[0] = true; + await loadOverview(); + return null; +} diff --git a/modules/manager2/webapp/js/pages/configuration.js b/modules/manager2/webapp/js/pages/configuration.js new file mode 100644 index 000000000000..3934e87aca32 --- /dev/null +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -0,0 +1,967 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { api } from '../api.js'; +import { el, clear, toast, modal, confirm, stateBadge } from '../ui.js'; + +const NUMERIC_TYPES = new Set(['int', 'long', 'short', 'byte', 'float', 'double']); +const RISKY_ATTRIBUTES = new Set(['name', 'path', 'defaultHost']); + +// Which structural child types can be added to a node of a given type. +// Independently of these, a lifecycle listener can be added to any node +// whose component implements Lifecycle (the node detail reports this as +// `acceptsListener`); see addChildModal. +const CHILD_TYPES = { + server: ['service'], + service: ['connector', 'executor'], + engine: ['host', 'realm', 'valve', 'cluster'], + host: ['context', 'alias', 'realm', 'valve', 'cluster'], + context: ['wrapper', 'realm', 'manager', 'resources', 'loader', 'cookieProcessor', 'valve', 'cluster'], + wrapper: [], + connector: ['sslHostConfig'], + sslHostConfig: ['certificate'], + certificate: [], + realm: [], + manager: ['sessionIdGenerator'], + cluster: ['channel', 'deployer', 'clusterValve', 'clusterManager', 'clusterListener'], + channel: ['membership', 'sender', 'receiver', 'interceptor'], + membership: [], + sender: ['transport'], + receiver: [], + interceptor: [], + deployer: [], + clusterManager: ['sessionIdGenerator'], + transport: [], + clusterValve: [], + clusterListener: [], + resources: [], + loader: [], + cookieProcessor: [], + sessionIdGenerator: [], + namingResources: ['resource', 'resourceLink', 'resourceEnvRef', 'environment', 'ejb', 'localEjb', 'serviceRef'], + resource: [], + resourceLink: [], + resourceEnvRef: [], + environment: [], + ejb: [], + localEjb: [], + serviceRef: [], + executor: [], + valve: [], + alias: [], + listener: [], +}; + +// The string parameters (RefAddr keys) consumed by the first party JNDI +// ObjectFactory implementations shipped with Tomcat. Mirrors the tables +// of the server (the entry detail gets them from there); used to +// pre-render the fields of the add dialog. Compact form: name, with a +// trailing ':b' (boolean) or ':i' (int) or ':l' (long) suffix. +const POOL_FACTORY_OPTIONS = [ + 'instanceKey', 'description', 'loginTimeout:i', 'blockWhenExhausted:b', + 'evictionPolicyClassName', 'lifo:b', 'maxIdlePerKey:i', 'maxTotalPerKey:i', + 'maxWaitMillis:l', 'minEvictableIdleTimeMillis:l', 'minIdlePerKey:i', + 'numTestsPerEvictionRun:i', 'softMinEvictableIdleTimeMillis:l', 'testOnCreate:b', + 'testOnBorrow:b', 'testOnReturn:b', 'testWhileIdle:b', + 'timeBetweenEvictionRunsMillis:l', 'validationQuery', 'validationQueryTimeout:i', + 'rollbackAfterValidation:b', 'maxConnLifetimeMillis:l', 'defaultAutoCommit:b', + 'defaultTransactionIsolation:i', 'defaultReadOnly:b', +]; +const FACTORY_OPTIONS = { + 'org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory': [ + 'defaultAutoCommit:b', 'defaultReadOnly:b', 'defaultTransactionIsolation', + 'defaultCatalog', 'defaultSchema', 'cacheState:b', 'driverClassName', 'lifo:b', + 'maxTotal:i', 'maxIdle:i', 'minIdle:i', 'initialSize:i', 'maxWaitMillis:l', + 'testOnCreate:b', 'testOnBorrow:b', 'testOnReturn:b', + 'timeBetweenEvictionRunsMillis:l', 'numTestsPerEvictionRun:i', + 'minEvictableIdleTimeMillis:l', 'softMinEvictableIdleTimeMillis:l', + 'evictionPolicyClassName', 'testWhileIdle:b', 'password', 'url', 'username', + 'validationQuery', 'validationQueryTimeout:l', 'connectionInitSqls', + 'accessToUnderlyingConnectionAllowed:b', 'removeAbandonedOnBorrow:b', + 'removeAbandonedOnMaintenance:b', 'removeAbandonedTimeout:l', 'logAbandoned:b', + 'abandonedUsageTracking', 'poolPreparedStatements:b', + 'clearStatementPoolOnReturn:b', 'maxOpenPreparedStatements:i', + 'connectionProperties', 'maxConnLifetimeMillis:l', 'logExpiredConnections:b', + 'rollbackOnReturn:b', 'enableAutoCommitOnReturn:b', 'defaultQueryTimeout:l', + 'fastFailValidation:b', 'disconnectionSqlCodes', 'disconnectionIgnoreSqlCodes', + 'jmxName', 'registerConnectionMBean:b', 'connectionFactoryClassName', + ], + 'org.apache.catalina.users.MemoryUserDatabaseFactory': [ + 'pathname', 'readonly:b', 'watchSource:b', + ], + 'org.apache.catalina.users.DataSourceUserDatabaseFactory': [ + 'dataSourceName', 'readonly:b', 'userTable', 'groupTable', 'roleTable', + 'userRoleTable', 'userGroupTable', 'groupRoleTable', 'roleNameCol', + 'roleAndGroupDescriptionCol', 'groupNameCol', 'userCredCol', + 'userFullNameCol', 'userNameCol', + ], + 'org.apache.tomcat.dbcp.dbcp2.datasources.PerUserPoolDataSourceFactory': [ + 'defaultMaxTotal:i', 'defaultMaxIdle:i', 'defaultMaxWaitMillis:l', + ...POOL_FACTORY_OPTIONS, + ], + 'org.apache.tomcat.dbcp.dbcp2.datasources.SharedPoolDataSourceFactory': [ + 'maxTotal:i', ...POOL_FACTORY_OPTIONS, + ], +}; +const BASIC_DATA_SOURCE_FACTORY = 'org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory'; + +// The JNDI entry types: all of them extend ResourceBase, which carries a +// generic map of string parameters (factory options and friends) that the +// JNDI factories consume at lookup time. Those parameters are shown in the +// entry detail and can be added, edited and removed there. +const NAMING_ENTRY_TYPES = new Set(['resource', 'resourceLink', 'resourceEnvRef', 'environment', 'ejb', 'localEjb', 'serviceRef']); + +// The factory a resource resolves to: the explicit factory, or the +// default the ResourceFactory dispatches the resource type to. +function effectiveFactory(jndiType, factory) { + if (factory) return factory; + if (jndiType === 'javax.sql.DataSource') return BASIC_DATA_SOURCE_FACTORY; + return null; +} + +// The option list of the effective factory of a resource, or null when +// the factory is not one of the first party factories with a closed set +// of options (their parameters stay free form). +function factoryOptions(jndiType, factory) { + const f = effectiveFactory(jndiType, factory); + return f ? (FACTORY_OPTIONS[f] || null) : null; +} + +// Component types that a context (or a manager) holds exactly one of. +// Adding one of these replaces the current instance. +const REPLACE_TYPES = new Set(['manager', 'resources', 'loader', 'cookieProcessor', 'sessionIdGenerator', + 'channel', 'membership', 'sender', 'receiver', 'deployer', 'clusterManager', 'transport']); + +// Build a request path for a node id. Each segment is percent encoded so +// that the container's single path decode yields exactly the (already +// encoded) id segments the server expects. +function nodePath(id) { + return id.split('/').map(encodeURIComponent).join('/'); +} + +export async function configuration(container) { + let selectedId = null; + let selectedDetail = null; + const expanded = new Set(['server']); + + const view = el('div', { class: 'config-page' }, + el('div', { class: 'page-head' }, + el('h1', {}, 'Configuration'), + el('p', {}, 'The live component tree of this server. Changes apply immediately; save to make them permanent.'), + el('span', { style: 'flex:1' }), + el('button', { type: 'button', class: 'btn', onclick: reloadAll }, 'Reload'), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => saveToServerXml() }, 'Save to server.xml'))); + view.append(el('div', { class: 'config-split' }, + el('div', { class: 'card config-tree-card' }, el('div', { class: 'card-title-row' }, + el('h3', {}, 'Components'))), + el('div', { class: 'card config-detail-card' }))); + container.append(view); + + const treeCard = view.querySelector('.config-tree-card'); + const detailCard = view.querySelector('.config-detail-card'); + const treeWrap = el('div', { class: 'config-tree' }); + treeCard.append(treeWrap); + + // ============================ Tree ================================== + + async function loadTree() { + const savedScroll = treeWrap.scrollTop; + clear(treeWrap); + treeWrap.append(el('div', { class: 'spinner', role: 'status', 'aria-label': 'Loading' })); + let data; + try { + data = await api('GET', '/api/config/tree'); + } catch (err) { + clear(treeWrap); + treeWrap.append(el('div', { class: 'empty' }, err.message)); + return; + } + clear(treeWrap); + treeWrap.append(buildTree(data.tree, 0)); + treeWrap.scrollTop = savedScroll; + } + + function buildTree(node, depth) { + const kids = node.children || []; + const hasKids = kids.length > 0; + const isOpen = expanded.has(node.id); + + const row = el('div', { class: 'config-node', style: 'padding-left:' + (depth * 16 + 4) + 'px' }, + el('button', { + type: 'button', class: 'config-node-toggle' + (hasKids ? '' : ' leaf'), + 'aria-label': hasKids ? 'Toggle' : '', + onclick: (e) => { + e.stopPropagation(); + if (!hasKids) return; + if (isOpen) expanded.delete(node.id); else expanded.add(node.id); + loadTree(); + }, + }, hasKids ? (isOpen ? '\u25BC' : '\u25B6') : ''), + el('span', { class: 'config-node-label', onclick: () => selectNode(node.id), style: 'cursor:pointer' }, + el('span', { class: 'config-type ' + node.type }, node.type), + el('span', { class: 'config-node-name' }, node.name || '(unnamed)'), + node.self ? el('span', { class: 'badge plain', style: 'margin-left:6px' }, 'this app') : '', + node.state ? el('span', { + class: 'badge ' + (node.state === 'STARTED' || node.state === 'AVAILABLE' ? 'ok' : 'stop'), + style: 'margin-left:6px', + }, node.state) : '')); + + const frag = document.createDocumentFragment(); + frag.append(row); + if (hasKids && isOpen) { + for (const child of kids) { + frag.append(buildTree(child, depth + 1)); + } + } + return frag; + } + + // ============================ Detail =============================== + + async function selectNode(id) { + selectedId = id; + clear(detailCard); + detailCard.append(el('div', { class: 'spinner', role: 'status', 'aria-label': 'Loading' })); + let data; + try { + data = await api('GET', '/api/config/node/' + nodePath(id)); + } catch (err) { + clear(detailCard); + detailCard.append(el('div', { class: 'empty' }, err.message)); + selectedDetail = null; + return; + } + selectedDetail = data; + renderDetail(); + } + + function renderDetail() { + const d = selectedDetail; + clear(detailCard); + + const head = el('div', { class: 'card-title-row' }, + el('div', { class: 'config-detail-title' }, + el('span', { class: 'config-type ' + d.type }, d.type), + el('h3', {}, d.name || '(unnamed)'), + d.state ? stateBadge(d.state) : ''), + el('div', { class: 'row-actions' }, + addable(d) ? el('button', { type: 'button', class: 'btn btn-sm', onclick: () => addChildModal(d) }, '+ Add') : null, + d.type !== 'server' ? el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + disabled: d.self, + title: d.self ? 'Cannot remove the component the manager is installed in' : 'Remove', + onclick: () => removeNode(d), + }, 'Remove') : null)); + detailCard.append(head); + + if (d.className) { + detailCard.append(el('p', { class: 'config-class' }, el('code', {}, d.className))); + } + + const props = d.properties || []; + const naming = NAMING_ENTRY_TYPES.has(d.type); + if (props.length || naming) { + const rows = props.map((p) => propRow(d, p)); + const head = el('div', { class: 'config-section-head' }, + el('h4', {}, 'Properties'), + naming ? el('button', { + type: 'button', class: 'btn btn-sm', + onclick: () => paramForm(head, d), + }, '+ Add parameter') : null); + const body = rows.length + ? el('div', { class: 'config-props' }, rows) + : el('p', { class: 'muted' }, 'No parameters set. Use "+ Add parameter" to add one.'); + detailCard.append(head, body); + } else { + detailCard.append(el('p', { class: 'muted' }, 'This component exposes no editable properties.')); + } + + const kids = d.children || []; + if (kids.length) { + detailCard.append(el('h4', {}, 'Children'), + el('div', { class: 'config-children' }, kids.map((k) => el('button', { + type: 'button', class: 'config-child-chip', onclick: () => selectNode(k.id), + }, k.type + ': ' + (k.name || '(unnamed)'))))); + } + } + + function propRow(node, p) { + const label = el('label', { class: 'config-prop-name' }, p.name, + p.param ? el('span', { class: 'config-param-hint' }, 'parameter') : '', + p.description ? el('span', { class: 'config-prop-desc', title: p.description }, p.description) : ''); + if (!p.writable) { + return el('div', { class: 'config-prop readonly' }, + label, + el('span', { class: 'config-prop-value muted' }, formatValue(p.value))); + } + const input = buildInput(p); + input.dataset.name = p.name; + const edit = el('div', { class: 'config-prop-edit' }, + input, + el('button', { + type: 'button', class: 'btn btn-sm', + onclick: () => applyProperty(node, p, input), + }, 'Apply')); + if (p.param) { + edit.append(el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + title: 'Remove this parameter', + onclick: () => removeParameter(node, p), + }, 'Remove')); + } + return el('div', { class: 'config-prop' }, label, edit); + } + + function buildInput(p) { + const t = p.type; + if (t === 'boolean') { + const box = el('input', { type: 'checkbox', class: 'config-check' }); + box.checked = Boolean(p.value); + return box; + } + if (NUMERIC_TYPES.has(t)) { + return el('input', { type: 'text', inputmode: 'numeric', value: p.value == null ? '' : String(p.value), class: 'config-input num' }); + } + if (t === '[Ljava.lang.String;') { + const arr = Array.isArray(p.value) ? p.value : (p.value == null ? [] : [p.value]); + return el('input', { type: 'text', value: arr.join(', '), class: 'config-input', placeholder: 'comma, separated' }); + } + if (t === 'java.lang.String') { + return el('input', { type: 'text', value: p.value == null ? '' : String(p.value), class: 'config-input' }); + } + // Non editable simple type: show as read only text. + const ro = el('input', { type: 'text', readonly: true, value: formatValue(p.value), class: 'config-input' }); + ro.disabled = true; + return ro; + } + + function formatValue(v) { + if (v === null || v === undefined) return ''; + if (Array.isArray(v)) return v.join(', '); + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + } + + function readInput(input, p) { + const t = p.type; + if (t === 'boolean') return input.checked; + if (NUMERIC_TYPES.has(t)) return input.value.trim(); + if (t === '[Ljava.lang.String;') { + return input.value.split(',').map((s) => s.trim()).filter(Boolean); + } + return input.value; + } + + async function applyProperty(node, p, input) { + let value = readInput(input, p); + // Guard against no-op writes. + if (sameValue(value, p.value)) { + toast('No changes to apply.', 'info'); + return; + } + if (RISKY_ATTRIBUTES.has(p.name)) { + const ok = await confirm({ + title: 'Change ' + p.name, + message: 'Changing ' + p.name + ' of ' + (node.name || node.type) + ' may break routing. Continue?', + confirmLabel: 'Change', + danger: true, + requireText: formatValue(p.value) || node.name, + }); + if (!ok) return; + } + try { + const res = await api('POST', '/api/config/attribute', { id: node.id, name: p.name, value }); + toast(res.message, 'ok'); + selectNode(selectedId); + } catch (err) { + toast(err.message, 'error'); + } + } + + // Toggle the inline "add a parameter" form below the Properties heading + // of a JNDI entry. The form is removed again when it is toggled off or + // when the entry detail is re-rendered. + function paramForm(heading, node) { + const existing = heading.parentElement.querySelector('.config-param-form'); + if (existing) { + existing.remove(); + return; + } + const nameInput = el('input', { type: 'text', class: 'config-input', placeholder: 'parameter name (e.g. url, maxTotal)' }); + const valueInput = el('input', { type: 'text', class: 'config-input', placeholder: 'value' }); + const form = el('div', { class: 'config-param-form' }, + nameInput, + valueInput, + el('button', { + type: 'button', class: 'btn btn-sm btn-primary', + onclick: () => addParameter(node, nameInput.value.trim(), valueInput.value), + }, 'Add'), + el('button', { type: 'button', class: 'btn btn-sm', onclick: () => form.remove() }, 'Cancel')); + heading.after(form); + nameInput.focus(); + } + + // Add a generic parameter to a JNDI entry. Any parameter name is + // accepted: the JNDI factory decides which ones it consumes at lookup + // time. + async function addParameter(node, name, value) { + if (!name) { + toast('A parameter name is required.', 'error'); + return; + } + try { + const res = await api('POST', '/api/config/attribute', { id: node.id, name, value }); + toast(res.message, 'ok'); + await selectNode(node.id); + } catch (err) { + toast(err.message, 'error'); + } + } + + // Remove a generic parameter from a JNDI entry by clearing it: the server + // drops parameters whose value is empty. + async function removeParameter(node, p) { + if (p.value === null || p.value === undefined || p.value === '') { + toast('This parameter is already empty.', 'info'); + return; + } + try { + const res = await api('POST', '/api/config/attribute', { id: node.id, name: p.name, value: '' }); + toast(res.message, 'ok'); + await selectNode(node.id); + } catch (err) { + toast(err.message, 'error'); + } + } + + function sameValue(a, b) { + if (Array.isArray(a) || Array.isArray(b)) { + const aa = Array.isArray(a) ? a : [a]; + const bb = Array.isArray(b) ? b : [b]; + if (aa.length !== bb.length) return false; + return aa.every((x, i) => String(x) === String(bb[i])); + } + return String(a) === String(b); + } + + function addableTypes(node) { + const types = (CHILD_TYPES[node.type] || []).slice(); + // Only combined realms accept (sub) realms; the node detail reports + // this as `acceptsSubRealm` (not derivable from the class name). + if (node.type === 'realm' && node.acceptsSubRealm) types.push('realm'); + // The server level naming resources do not accept resource links + // (they are not parsed from ). + if (node.type === 'namingResources' && node.global) { + const i = types.indexOf('resourceLink'); + if (i >= 0) types.splice(i, 1); + } + if (node.acceptsListener) types.push('listener'); + return types; + } + + function addable(node) { + return addableTypes(node).length > 0; + } + + // ============================ Add child ============================ + + function addChildModal(parent) { + const types = addableTypes(parent); + if (!types.length) return; + + const select = el('select', { class: 'config-input' }, + types.map((t) => el('option', { value: t }, t))); + const fieldsWrap = el('div', { class: 'form-grid' }); + const noteWrap = el('div', {}); + + function currentCtx(type) { + if (type !== 'resource') return {}; + const t = document.getElementById('c-type'); + const f = document.getElementById('c-factory'); + return { jndiType: t ? t.value.trim() : '', factory: f ? f.value.trim() : '' }; + } + + function renderFields() { + const type = select.value; + const ctx = currentCtx(type); + // Remember the values already entered so that a re-render (the + // factory options appear or change) does not lose them. + const previous = {}; + for (const [key, id] of childFieldIds(type, ctx)) { + const node = document.getElementById(id); + if (node) previous[id] = node.type === 'checkbox' ? node.checked : node.value; + } + clear(fieldsWrap); + clear(noteWrap); + for (const [label, input, span2] of childFields(type, ctx)) { + fieldsWrap.append(span2 + ? el('div', { class: 'field span-2' }, el('label', {}, label), input) + : el('div', { class: 'field' }, el('label', {}, label), input)); + } + for (const id in previous) { + const node = document.getElementById(id); + if (!node) continue; + if (node.type === 'checkbox') node.checked = previous[id]; + else node.value = previous[id]; + } + // A context (or a manager) holds exactly one of the "replace" + // component types: adding one replaces the current instance. + if (REPLACE_TYPES.has(type)) { + const current = (parent.children || []).find((c) => c.type === type); + if (current) { + noteWrap.append(el('p', { class: 'muted' }, + 'Replaces the current ' + type + ' (' + (current.name || current.className) + ').')); + } + } + if (type === 'resource') { + noteWrap.append(el('p', { class: 'muted' }, + 'The options of a first party factory are shown as fields; further parameters can be edited in the entry detail.')); + } + } + // The factory options of a resource depend on the (effective) + // factory; re-render when the user leaves those fields. + fieldsWrap.addEventListener('change', (e) => { + if (select.value === 'resource' + && (e.target.id === 'c-factory' || e.target.id === 'c-type')) { + renderFields(); + } + }); + select.addEventListener('change', renderFields); + renderFields(); + + const body = el('div', {}, + el('div', { class: 'field' }, el('label', {}, 'Component type'), select), + fieldsWrap, + noteWrap); + + modal({ + title: 'Add ' + parent.type + ' child', + content: body, + actions: [ + { label: 'Cancel' }, + { + label: 'Add', + class: 'btn-primary', + onClick: async (close) => { + const type = select.value; + const bodyObj = { parent: parent.id, type }; + for (const [key, id] of childFieldIds(type, currentCtx(type))) { + const node = document.getElementById(id); + if (!node) continue; + if (node.type === 'checkbox') { + if (node.checked) assignField(bodyObj, key, true); + } else { + const value = node.value.trim(); + // Empty fields (and the "(default)" placeholder of the + // certificate type select) are omitted from the payload. + if (value === '' || value === '(default)') continue; + assignField(bodyObj, key, value); + } + } + try { + const res = await api('POST', '/api/config/child', bodyObj); + toast(res.message, 'ok'); + close(); + await loadTree(); + selectNode(parent.id); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + } + + const CERT_TYPES = ['(default)', 'RSA', 'DSA', 'EC', 'MLDSA']; + + // The fields shared by a certificate: the type plus the keystore + // location (and, for PEM files, the individual file paths). + function certificateFields() { + return [ + ['Certificate type', select('c-cert-type', CERT_TYPES)], + ['Keystore file', input('c-cert-file', 'text', 'conf/keystore.p12'), true], + ['Keystore password', input('c-cert-pass', 'password', 'changeit')], + ['Key alias', input('c-cert-alias', 'text', 'tomcat')], + ['Keystore type', input('c-cert-storetype', 'text', 'PKCS12')], + ]; + } + + function certificateFieldIds() { + return [ + ['type', 'c-cert-type'], + ['certificateKeystoreFile', 'c-cert-file'], + ['certificateKeystorePassword', 'c-cert-pass'], + ['certificateKeyAlias', 'c-cert-alias'], + ['certificateKeystoreType', 'c-cert-storetype'], + ]; + } + + // One form field per option of the effective factory of a new + // resource (the first party factories with a closed set of options). + function resourceOptionFields(jndiType, factory) { + const options = factoryOptions(jndiType, factory); + if (!options) return { fields: [], ids: [] }; + const fields = []; + const ids = []; + for (const spec of options) { + const sep = spec.lastIndexOf(':'); + const name = sep < 0 ? spec : spec.slice(0, sep); + const kind = sep < 0 ? 'text' : spec.slice(sep + 1); + const id = 'c-param-' + name; + if (kind === 'b') { + fields.push([name, checkbox(id)]); + ids.push(['params.' + name, id]); + } else { + fields.push([name, input(id, 'text', kind === 'i' || kind === 'l' ? 'number' : '')]); + ids.push(['params.' + name, id]); + } + } + return { fields, ids }; + } + + // The form fields of the add dialog of a child type. ctx carries the + // values of the fields a form depends on (the JNDI type and factory + // of a resource). + function childFields(type, ctx) { + switch (type) { + case 'service': + return [['Name', input('c-name', 'text', 'Catalina2')]]; + case 'resource': { + const fields = [ + ['JNDI name', input('c-name', 'text', 'jdbc/MyDB')], + ['Type', input('c-type', 'text', 'javax.sql.DataSource')], + ['Factory', input('c-factory', 'text', BASIC_DATA_SOURCE_FACTORY), true], + ['Auth', input('c-auth', 'text', 'Container')], + ]; + fields.push(...resourceOptionFields(ctx && ctx.jndiType, ctx && ctx.factory).fields); + return fields; + } + case 'resourceLink': + return [ + ['JNDI name', input('c-name', 'text', 'jdbc/MyDB')], + ['Type', input('c-type', 'text', 'javax.sql.DataSource')], + ['Global JNDI name', input('c-global', 'text', 'jdbc/MyGlobalDB')], + ['Factory', input('c-factory', 'text', ''), true], + ]; + case 'resourceEnvRef': + return [ + ['JNDI name', input('c-name', 'text', 'jdbc/MyDB')], + ['Type', input('c-type', 'text', 'javax.sql.DataSource')], + ]; + case 'environment': + return [ + ['JNDI name', input('c-name', 'text', 'mail/Session')], + ['Type', input('c-type', 'text', 'java.lang.String')], + ['Value', input('c-value', 'text', '')], + ]; + case 'ejb': + return [ + ['JNDI name', input('c-name', 'text', 'ejb/MyBean')], + ['Type (home interface)', input('c-type', 'text', 'org.example.MyBeanHome')], + ['Link', input('c-link', 'text', '')], + ]; + case 'localEjb': + return [ + ['JNDI name', input('c-name', 'text', 'ejb/MyBean')], + ['Type (local home)', input('c-type', 'text', 'org.example.MyBeanLocalHome')], + ['Local (business interface)', input('c-local', 'text', '')], + ['Link', input('c-link', 'text', '')], + ]; + case 'serviceRef': + return [ + ['JNDI name', input('c-name', 'text', 'service/MyService')], + ['Type (service interface)', input('c-type', 'text', 'org.example.MyService')], + ['Display name', input('c-displayname', 'text', '')], + ]; + case 'host': + return [ + ['Name', input('c-name', 'text', 'example.com')], + ['Aliases (comma separated)', input('c-aliases', 'text', 'www.example.com')], + ['App base', input('c-appbase', 'text', 'webapps/example.com'), true], + ]; + case 'context': + return [ + ['Path', input('c-path', 'text', '/myapp')], + ['Display name', input('c-display', 'text', '')], + ['Doc base', input('c-docbase', 'text', 'relative to the host app base, or a .war'), true], + ]; + case 'wrapper': + return [ + ['Name', input('c-name', 'text', 'myservlet')], + ['Servlet class', input('c-servlet', 'text', 'org.example.MyServlet'), true], + ['URL patterns (comma separated)', input('c-urlpats', 'text', '/hello, /hi'), true], + ]; + case 'valve': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.valves.AccessLogValve'), true]]; + case 'listener': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.mbeans.GlobalResourcesLifecycleListener'), true]]; + case 'cluster': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.ha.tcp.SimpleTcpCluster'), true]]; + case 'clusterValve': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.ha.tcp.ReplicationValve'), true]]; + case 'channel': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.group.GroupChannel'), true]]; + case 'membership': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.membership.McastService'), true]]; + case 'sender': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.transport.ReplicationTransmitter'), true]]; + case 'receiver': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.transport.nio.NioReceiver'), true]]; + case 'interceptor': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor'), true]]; + case 'deployer': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.ha.deploy.FarmWarDeployer'), true]]; + case 'clusterManager': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.ha.session.DeltaManager'), true]]; + case 'transport': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.tribes.transport.nio.PooledParallelSender'), true]]; + case 'clusterListener': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.ha.session.ClusterSessionListener'), true]]; + case 'realm': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.realm.UserDatabaseRealm'), true]]; + case 'manager': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.session.StandardManager'), true]]; + case 'sessionIdGenerator': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.util.StandardSessionIdGenerator'), true]]; + case 'resources': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.webresources.StandardRoot'), true]]; + case 'loader': + return [['Class name', input('c-class', 'text', 'org.apache.catalina.loader.WebappLoader'), true]]; + case 'cookieProcessor': + return [['Class name', input('c-class', 'text', 'org.apache.tomcat.util.http.Rfc6265CookieProcessor'), true]]; + case 'connector': + return [ + ['Protocol', input('c-protocol', 'text', 'HTTP/1.1')], + ['Port', input('c-port', 'text', '8081')], + ]; + case 'executor': + return [ + ['Name', input('c-name', 'text', 'tomcatThreadPool')], + ['Max threads', input('c-maxthreads', 'text', '150')], + ['Min spare threads', input('c-minspare', 'text', '4')], + ]; + case 'alias': + return [['Alias', input('c-alias', 'text', 'www.example.com')]]; + case 'sslHostConfig': + return [ + ['Host name', input('c-hostname', 'text', '_default_'), true], + ...certificateFields(), + ]; + case 'certificate': + return certificateFields(); + default: + return []; + } + } + + function childFieldIds(type, ctx) { + switch (type) { + case 'service': return [['name', 'c-name']]; + case 'resource': { + const ids = [ + ['name', 'c-name'], + ['jndiType', 'c-type'], + ['factory', 'c-factory'], + ['auth', 'c-auth'], + ]; + ids.push(...resourceOptionFields(ctx && ctx.jndiType, ctx && ctx.factory).ids); + return ids; + } + case 'resourceLink': + return [['name', 'c-name'], ['jndiType', 'c-type'], ['global', 'c-global'], ['factory', 'c-factory']]; + case 'resourceEnvRef': + return [['name', 'c-name'], ['jndiType', 'c-type']]; + case 'environment': + return [['name', 'c-name'], ['jndiType', 'c-type'], ['value', 'c-value']]; + case 'ejb': + return [['name', 'c-name'], ['jndiType', 'c-type'], ['link', 'c-link']]; + case 'localEjb': + return [['name', 'c-name'], ['jndiType', 'c-type'], ['local', 'c-local'], ['link', 'c-link']]; + case 'serviceRef': + return [['name', 'c-name'], ['jndiType', 'c-type'], ['displayname', 'c-displayname']]; + case 'host': return [['name', 'c-name'], ['aliases', 'c-aliases'], ['appBase', 'c-appbase']]; + case 'context': return [['path', 'c-path'], ['displayName', 'c-display'], ['docBase', 'c-docbase']]; + case 'wrapper': return [['name', 'c-name'], ['servletClass', 'c-servlet'], ['urlPatterns', 'c-urlpats']]; + case 'valve': return [['className', 'c-class']]; + case 'listener': return [['className', 'c-class']]; + case 'cluster': return [['className', 'c-class']]; + case 'clusterValve': return [['className', 'c-class']]; + case 'channel': return [['className', 'c-class']]; + case 'membership': return [['className', 'c-class']]; + case 'sender': return [['className', 'c-class']]; + case 'receiver': return [['className', 'c-class']]; + case 'interceptor': return [['className', 'c-class']]; + case 'deployer': return [['className', 'c-class']]; + case 'clusterManager': return [['className', 'c-class']]; + case 'transport': return [['className', 'c-class']]; + case 'clusterListener': return [['className', 'c-class']]; + case 'realm': return [['className', 'c-class']]; + case 'manager': return [['className', 'c-class']]; + case 'sessionIdGenerator': return [['className', 'c-class']]; + case 'resources': return [['className', 'c-class']]; + case 'loader': return [['className', 'c-class']]; + case 'cookieProcessor': return [['className', 'c-class']]; + case 'connector': return [['protocol', 'c-protocol'], ['port', 'c-port']]; + case 'executor': return [['name', 'c-name'], ['maxThreads', 'c-maxthreads'], ['minSpareThreads', 'c-minspare']]; + case 'alias': return [['alias', 'c-alias']]; + case 'sslHostConfig': { + // The initial certificate is nested under the 'certificate' + // object of the request body. + const ids = [['hostName', 'c-hostname']]; + for (const [key, id] of certificateFieldIds()) ids.push(['certificate.' + key, id]); + return ids; + } + case 'certificate': return certificateFieldIds(); + default: return []; + } + } + + function input(id, type, placeholder) { + return el('input', { id, type, placeholder: placeholder || '', class: 'config-input' }); + } + + function checkbox(id) { + return el('input', { id, type: 'checkbox', class: 'config-check' }); + } + + function select(id, options) { + const node = el('select', { id, class: 'config-input' }); + for (const option of options) { + node.append(el('option', { value: option }, option)); + } + return node; + } + + // Assign a value to a (possibly nested) key of the request payload: + // 'certificate.type' becomes bodyObj.certificate.type. + function assignField(bodyObj, key, value) { + const parts = key.split('.'); + let target = bodyObj; + for (let i = 0; i < parts.length - 1; i++) { + if (typeof target[parts[i]] !== 'object' || target[parts[i]] === null) { + target[parts[i]] = {}; + } + target = target[parts[i]]; + } + target[parts[parts.length - 1]] = value; + } + + // ============================ Remove =============================== + + async function removeNode(d) { + const label = d.name || d.type; + const ok = await confirm({ + title: 'Remove ' + d.type, + message: 'Remove ' + label + ' from the running server? This cannot be undone without a reload.', + confirmLabel: 'Remove', + danger: true, + requireText: label, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/config/child', { id: d.id, confirm: label }); + toast(res.message, 'ok'); + selectedId = null; + selectedDetail = null; + clear(detailCard); + renderEmpty(); + await loadTree(); + } catch (err) { + toast(err.message, 'error'); + } + } + + function renderEmpty() { + detailCard.append(el('div', { class: 'empty' }, + el('p', {}, 'Select a component in the tree to inspect and edit it.'))); + } + + // ============================ Save to server.xml =================== + + async function saveToServerXml() { + let data; + try { + data = await api('GET', '/api/config/store/preview'); + } catch (err) { + toast(err.message, 'error'); + return; + } + const xml = data.xml; + const files = data.files || []; + const pre = el('pre', { class: 'config-xml' }, xml); + const input = el('input', { type: 'text', autocomplete: 'off', placeholder: 'server.xml', class: 'config-input' }); + const body = el('div', {}, + el('p', { style: 'margin-top:0;color:var(--text-soft)' }, + 'This will overwrite conf/server.xml with the live state (a timestamped backup is kept).')); + if (files.length) { + body.append(el('p', { style: 'color:var(--text-soft)' }, + 'It will also rewrite the following context configuration files:'), + el('ul', { class: 'config-file-list' }, + files.map((f) => el('li', {}, el('code', {}, f))))); + } + body.append( + el('div', { class: 'field', style: 'margin-bottom:12px' }, + el('label', {}, 'Type ', el('code', {}, 'server.xml'), ' to confirm'), input), + pre); + if (data.restartsManager) { + body.append(el('div', { class: 'config-store-warning' }, + el('strong', {}, 'Warning: '), + 'the file of the context this manager runs in is among them. Saving will restart the manager and reset your session - you will need to log in again.')); + } + modal({ + title: 'Save to server.xml', + wide: true, + content: body, + actions: [ + { label: 'Cancel' }, + { + label: 'Save', + class: 'btn-primary', + onClick: async (close) => { + if (input.value.trim() !== 'server.xml') { + input.focus(); + return; + } + try { + const res = await api('POST', '/api/config/store', {}); + toast(res.message, 'ok'); + close(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + } + + // ============================ Misc ================================= + + async function reloadAll() { + await loadTree(); + if (selectedId) { + selectNode(selectedId); + } else { + renderEmpty(); + } + } + + await loadTree(); + renderEmpty(); + return null; +} diff --git a/modules/manager2/webapp/js/pages/dashboard.js b/modules/manager2/webapp/js/pages/dashboard.js new file mode 100644 index 000000000000..f7fd45cf6d91 --- /dev/null +++ b/modules/manager2/webapp/js/pages/dashboard.js @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { BASE, get } from '../api.js'; +import { el, clear, formatBytes, formatRate, formatDuration, stateBadge } from '../ui.js'; +import { LineChart, palette } from '../charts.js'; + +// Fallback poll period if the server does not (yet) report one. +const FALLBACK_TICK_MS = 2000; + +/** + * Build a card with a title and a live indicator. + */ +function chartCard(title, extra) { + return el('div', { class: 'card chart-card' + (extra ? ' ' + extra : '') }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), title)), + el('canvas')); +} + +export async function dashboard(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, 'Dashboard'), + el('p', {}, ''))); + const subtitle = view.querySelector('.page-head p'); + container.append(view); + + // ---------------- KPI cards ---------------- + const kpiGrid = el('div', { class: 'grid kpis' }); + const kpiRefs = {}; + function kpi(key, label) { + const card = el('div', { class: 'card kpi' }, + el('span', { class: 'kpi-label' }, label), + el('span', { class: 'kpi-value' }, '-'), + el('span', { class: 'kpi-sub' }, '')); + kpiRefs[key] = { + value: card.querySelector('.kpi-value'), + sub: card.querySelector('.kpi-sub'), + }; + kpiGrid.append(card); + } + kpi('heap', 'Heap used'); + kpi('threads', 'Busy threads'); + kpi('sessions', 'Active sessions'); + kpi('rps', 'Requests / s'); + kpi('errors', 'Errors / s'); + view.append(kpiGrid); + + // ---------------- Charts ---------------- + const grid = el('div', { class: 'grid charts' }); + const heapCard = chartCard('JVM heap', 'col-6'); + const heapCanvas = heapCard.querySelector('canvas'); + const heapChart = new LineChart(heapCanvas, { + series: [ + { name: 'used', color: palette(0) }, + { name: 'committed', color: palette(1) }, + ], + windowMs: 10 * 60 * 1000, + formatValue: (v) => formatBytes(v), + }); + heapCard.append(el('div', { class: 'chart-legend' }, + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(0) }), 'used'), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(1) }), 'committed'))); + + const threadsCard = chartCard('Busy threads', 'col-6'); + const threadsCanvas = threadsCard.querySelector('canvas'); + const threadsChart = new LineChart(threadsCanvas, { + series: [{ name: 'busy', color: palette(2) }], + windowMs: 10 * 60 * 1000, + formatValue: (v) => String(Math.round(v)), + }); + + const rateCard = chartCard('Request rate', 'col-6'); + const rateCanvas = rateCard.querySelector('canvas'); + const rateChart = new LineChart(rateCanvas, { + series: [ + { name: 'requests', color: palette(1) }, + { name: 'errors', color: palette(3) }, + ], + windowMs: 10 * 60 * 1000, + formatValue: (v) => v.toFixed(1), + }); + rateCard.append(el('div', { class: 'chart-legend' }, + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(1) }), 'requests/s'), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(3) }), 'errors/s'))); + + const bytesCard = chartCard('Network', 'col-6'); + const bytesCanvas = bytesCard.querySelector('canvas'); + const bytesChart = new LineChart(bytesCanvas, { + series: [ + { name: 'sent', color: palette(4) }, + { name: 'received', color: palette(5) }, + ], + windowMs: 10 * 60 * 1000, + formatValue: (v) => formatBytes(v), + }); + bytesCard.append(el('div', { class: 'chart-legend' }, + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(4) }), 'sent B/s'), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(5) }), 'received B/s'))); + + grid.append(heapCard, threadsCard, rateCard, bytesCard); + view.append(grid); + + // ---------------- Applications strip ---------------- + const appsCard = el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), 'Applications')), + el('div', { class: 'table-wrap' })); + const appsTableWrap = appsCard.querySelector('.table-wrap'); + view.append(appsCard); + + // ---------------- Rendering ---------------- + // The charts are driven by the history the server collects in the + // background (one sample per tick, keeping the configured window). Each + // poll therefore renders the full window; nothing is accumulated in the + // browser, so the charts always show the last windowMs of server activity + // no matter when the page was opened. + let subtitleSet = false; + + function render(data) { + if (!subtitleSet) { + subtitle.textContent = 'Charts show the last ' + formatDuration(data.windowMs) + + ' of server activity. One sample every ' + (data.tickMs / 1000) + + ' s, collected in the background.'; + subtitleSet = true; + } + + const sample = data.samples.length > 0 ? data.samples[data.samples.length - 1] : null; + if (sample) { + kpiRefs.heap.value.textContent = formatBytes(sample.heapUsed); + kpiRefs.heap.sub.textContent = 'of ' + formatBytes(sample.heapMax); + kpiRefs.threads.value.textContent = sample.threadsBusy + ' / ' + sample.threadsMax; + kpiRefs.sessions.value.textContent = String(sample.sessions); + // The rates are computed by the server; the first sample has no + // baseline yet, so the rates are null there (rendered as '-'). + kpiRefs.rps.value.textContent = formatRate(sample.rps); + kpiRefs.errors.value.textContent = formatRate(sample.eps); + } + + heapChart.windowMs = data.windowMs; + threadsChart.windowMs = data.windowMs; + rateChart.windowMs = data.windowMs; + bytesChart.windowMs = data.windowMs; + heapChart.setData(data.samples.map((s) => [s.ts, [s.heapUsed, s.heapCommitted]])); + threadsChart.setData(data.samples.map((s) => [s.ts, [s.threadsBusy]])); + rateChart.setData(data.samples.map((s) => [s.ts, [s.rps, s.eps]])); + bytesChart.setData(data.samples.map((s) => [s.ts, [s.bpsSent, s.bpsRecv]])); + + // Applications table + clear(appsTableWrap); + const apps = data.apps || []; + const seg = (a) => encodeURIComponent(a.path === '' ? 'root' : a.path.replace(/^\//, '')); + const rows = apps.map((a) => el('tr', {}, + el('td', {}, + el('a', { + href: BASE + '/apps/' + a.host + '/' + seg(a), + onclick: (e) => { + e.preventDefault(); + window.history.pushState({}, '', BASE + '/apps/' + a.host + '/' + seg(a)); + window.dispatchEvent(new PopStateEvent('popstate')); + }, + }, a.path === '' ? '/' : a.path)), + el('td', {}, stateBadge(a.state)), + el('td', { class: 'num' }, String(a.activeSessions)))); + appsTableWrap.append(el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Path'), + el('th', {}, 'State'), + el('th', {}, 'Sessions'))), + el('tbody', {}, rows.length > 0 ? rows + : el('tr', {}, el('td', { colspan: '3', class: 'empty' }, 'No applications deployed'))))); + } + + // ---------------- Polling ---------------- + let stopped = false; + let timer = 0; + + function schedule(delay) { + if (stopped) return; + timer = setTimeout(tick, delay > 0 ? delay : FALLBACK_TICK_MS); + } + + async function tick() { + if (stopped) return; + if (document.hidden) { + // Paused while the tab is hidden; the server keeps collecting. + schedule(FALLBACK_TICK_MS); + return; + } + let data; + try { + data = await get('/api/status/history'); + } catch (err) { + schedule(FALLBACK_TICK_MS); // transient; next tick retries + return; + } + if (stopped) return; + render(data); + schedule(data.tickMs); + } + + await tick(); + + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/modules/manager2/webapp/js/pages/diagnostics.js b/modules/manager2/webapp/js/pages/diagnostics.js new file mode 100644 index 000000000000..d4cc82d926cd --- /dev/null +++ b/modules/manager2/webapp/js/pages/diagnostics.js @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { api } from '../api.js'; +import { el, clear, toast, confirm } from '../ui.js'; + +export async function diagnostics(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, 'Diagnostics'), + el('p', {}, 'SSL, memory leaks, JNDI resources and JVM diagnostics.'))); + container.append(view); + + const tabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active' }, 'SSL'), + el('button', { type: 'button', class: 'tab' }, 'Memory leaks'), + el('button', { type: 'button', class: 'tab' }, 'JNDI resources'), + el('button', { type: 'button', class: 'tab' }, 'JVM')); + const panes = el('div', {}, + el('div', { class: 'pane' }), + el('div', { class: 'pane', style: 'display:none' }), + el('div', { class: 'pane', style: 'display:none' }), + el('div', { class: 'pane', style: 'display:none' })); + view.append(tabs, panes); + + const tabButtons = tabs.querySelectorAll('.tab'); + const paneNodes = panes.querySelectorAll('.pane'); + const loaders = [loadSsl, loadLeaks, loadResources, loadJvm]; + let loaded = [false, false, false, false]; + + function switchTab(index) { + tabButtons.forEach((t, i) => t.classList.toggle('active', i === index)); + paneNodes.forEach((p, i) => { p.style.display = i === index ? '' : 'none'; }); + if (!loaded[index]) { + loaded[index] = true; + loaders[index](); + } + } + tabButtons.forEach((t, i) => t.addEventListener('click', () => switchTab(i))); + + // ---------------- SSL ---------------- + async function loadSsl() { + const pane = paneNodes[0]; + const reloadRow = el('div', { class: 'row-actions', style: 'margin-bottom:14px;' }, + el('input', { + type: 'text', placeholder: 'TLS SNI host name (optional)', style: 'width:280px;', id: 'tls-host', + }), + el('button', { + type: 'button', class: 'btn btn-sm', + onclick: async () => { + const ok = await confirm({ + title: 'Reload SSL', + message: 'Reload the SSL context? Connections in flight are interrupted.', + confirmLabel: 'Reload', + }); + if (!ok) return; + const body = {}; + const host = document.getElementById('tls-host').value.trim(); + if (host) body.tlsHostName = host; + try { + const res = await api('POST', '/api/ssl/reload', body); + toast(res.message, 'ok'); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, 'Reload SSL context')); + const list = el('div'); + pane.append(reloadRow, list); + + const subTabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active' }, 'Cipher suites'), + el('button', { type: 'button', class: 'tab' }, 'Certificates'), + el('button', { type: 'button', class: 'tab' }, 'Trusted certificates')); + list.append(subTabs, el('div', { id: 'ssl-content' })); + + let current = 'ciphers'; + const subButtons = subTabs.querySelectorAll('.tab'); + const urls = { ciphers: '/api/ssl/ciphers', certs: '/api/ssl/certs', trusted: '/api/ssl/trusted' }; + subButtons.forEach((btn, i) => btn.addEventListener('click', () => { + subButtons.forEach((b, j) => b.classList.toggle('active', i === j)); + current = Object.keys(urls)[i]; + loadCurrent(); + })); + + async function loadCurrent() { + const holder = list.querySelector('#ssl-content'); + clear(holder); + let data; + try { + data = await api('GET', urls[current]); + } catch (err) { + holder.append(el('div', { class: 'empty' }, err.message)); + return; + } + const entries = Object.entries(data); + if (entries.length === 0) { + holder.append(el('div', { class: 'empty' }, + 'No SSL connector configured on this server.')); + return; + } + for (const [connector, values] of entries) { + holder.append(el('div', { class: 'card' }, + el('h3', {}, connector), + el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('tbody', {}, values.map((v) => el('tr', {}, + el('td', {}, el('code', {}, v))))))))); + } + } + await loadCurrent(); + } + + // ---------------- Memory leaks ---------------- + function loadLeaks() { + const pane = paneNodes[1]; + const holder = el('div', { style: 'margin-top:14px;' }); + pane.append(el('div', { class: 'card' }, + el('p', { style: 'color:var(--text-soft);margin-top:0;' }, + 'Check whether the deployed web applications hold any memory-leaking references ' + + '(class loaders, threads or file handles).'), + el('div', { class: 'row-actions' }, + el('button', { + type: 'button', class: 'btn', + onclick: async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + clear(holder); + holder.append(document.createTextNode('Checking… this can take a while.')); + try { + const data = await api('GET', '/api/leaks'); + clear(holder); + if (!data.leaks || data.leaks.length === 0) { + holder.append(el('div', { class: 'empty' }, 'No leaks found.')); + } else { + holder.append(el('pre', { class: 'block' }, data.leaks.join('\n'))); + } + } catch (err) { + clear(holder); + holder.append(el('div', { class: 'empty' }, err.message)); + } finally { + btn.disabled = false; + } + }, + }, 'Check for leaks')), + holder)); + } + + // ---------------- JNDI resources ---------------- + async function loadResources() { + const pane = paneNodes[2]; + pane.append(el('div', { class: 'card' }, + el('div', { class: 'row-actions', style: 'margin-bottom:14px;' }, + el('select', { id: 'res-type', style: 'width:220px;' }, + el('option', { value: '' }, 'All types'), + el('option', { value: 'env/java:comp/env' }, 'env/java:comp/env'), + el('option', { value: 'env/ejb' }, 'env/ejb'), + el('option', { value: 'env/jndi/kerberos' }, 'env/jndi/kerberos')), + el('button', { + type: 'button', class: 'btn btn-sm', + onclick: () => loadResTable(), + }, 'Refresh')), + el('div', { id: 'resources-table' }))); + + async function loadResTable() { + const holder = pane.querySelector('#resources-table'); + const type = document.getElementById('res-type').value; + let data; + try { + data = await api('GET', '/api/resources' + (type ? '?type=' + encodeURIComponent(type) : '')); + } catch (err) { + clear(holder); + holder.append(el('div', { class: 'empty' }, err.message)); + return; + } + clear(holder); + const lines = (data.resources || '').split('\n').filter((l) => l.trim()); + if (lines.length === 0) { + holder.append(el('div', { class: 'empty' }, 'No resources found.')); + return; + } + holder.append(el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, el('th', {}, 'Name'), el('th', {}, 'Class'))), + el('tbody', {}, lines.map((line) => { + const idx = line.indexOf(':'); + const name = idx >= 0 ? line.substring(0, idx).trim() : line; + const cls = idx >= 0 ? line.substring(idx + 1).trim() : ''; + return el('tr', {}, + el('td', {}, el('code', {}, name)), + el('td', { class: 'muted' }, el('code', {}, cls || '-'))); + }))))); + } + await loadResTable(); + } + + // ---------------- JVM ---------------- + function loadJvm() { + const pane = paneNodes[3]; + const holder = el('div'); + pane.append(el('div', { class: 'card' }, + el('p', { style: 'color:var(--text-soft);margin-top:0;' }, + 'VM information and a thread dump of this process.'), + el('div', { class: 'row-actions' }, + el('button', { + type: 'button', class: 'btn', + onclick: (e) => show('info', e.currentTarget), + }, 'VM information'), + el('button', { + type: 'button', class: 'btn', + onclick: (e) => show('threaddump', e.currentTarget), + }, 'Thread dump')), + holder)); + + async function show(which, btn) { + const title = which === 'info' ? 'VM information' : 'Thread dump'; + const url = which === 'info' ? '/api/diagnostics/vminfo' : '/api/diagnostics/threaddump'; + let section = holder.querySelector('#jvm-' + which); + let body; + if (section) { + body = section.querySelector('.jvm-body'); + } else { + body = el('div', { class: 'jvm-body' }); + section = el('div', { id: 'jvm-' + which, style: 'margin-top:14px;' }, + el('h3', {}, title), body); + holder.append(section); + } + clear(body); + body.append(el('div', { class: 'spinner' })); + btn.disabled = true; + try { + const data = await api('GET', url); + const text = which === 'info' ? data.info : data.dump; + clear(body); + body.append(el('pre', { class: 'block' }, text || '(empty)')); + } catch (err) { + clear(body); + body.append(el('div', { class: 'empty' }, err.message)); + } finally { + btn.disabled = false; + } + } + } + + await loaders[0](); + return null; +} diff --git a/modules/manager2/webapp/js/pages/hosts.js b/modules/manager2/webapp/js/pages/hosts.js new file mode 100644 index 000000000000..57abf70715cd --- /dev/null +++ b/modules/manager2/webapp/js/pages/hosts.js @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { api } from '../api.js'; +import { el, clear, table, toast, modal, confirm } from '../ui.js'; + +export async function hosts(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, 'Virtual hosts'), + el('p', {}, 'Add, start, stop and remove virtual hosts on this engine.'), + el('span', { style: 'flex:1' }), + el('button', { type: 'button', class: 'btn', onclick: () => persist() }, 'Save to server.xml'), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => addHostModal() }, 'Add host'))); + container.append(view); + + const wrap = el('div', { class: 'card' }); + view.append(wrap); + + async function load() { + let data; + try { + data = await api('GET', '/api/hosts'); + } catch (err) { + wrap.append(el('div', { class: 'empty' }, err.message)); + return; + } + clear(wrap); + wrap.append(table({ + columns: [ + { key: 'name', label: 'Name' }, + { key: 'aliases', label: 'Aliases', muted: true, render: (h) => h.aliases.join(', ') || '-' }, + { + key: 'appBase', label: 'App base', muted: true, + render: (h) => el('code', {}, h.appBase || '-'), + }, + { + key: 'state', label: 'State', + render: (h) => el('span', { class: 'badge ' + (h.started ? 'ok' : 'stop') }, + h.started ? 'Running' : 'Stopped'), + }, + { key: 'self', label: '', render: (h) => h.self ? el('span', { class: 'badge plain' }, 'this host') : '' }, + { + key: 'actions', label: 'Actions', + render: (h) => el('div', { class: 'row-actions' }, + h.started + ? el('button', { + type: 'button', class: 'btn btn-sm', disabled: h.self, + onclick: () => startStop(h, 'stop'), + }, 'Stop') + : el('button', { + type: 'button', class: 'btn btn-sm btn-primary', disabled: h.self, + onclick: () => startStop(h, 'start'), + }, 'Start'), + el('button', { + type: 'button', class: 'btn btn-sm btn-danger', disabled: h.self, + title: h.self ? 'Cannot remove the host the manager is installed in' : 'Remove', + onclick: () => removeHost(h), + }, 'Remove')), + }], + rows: data, + empty: 'No virtual hosts configured', + })); + } + + async function startStop(host, action) { + const ok = action === 'stop' + ? await confirm({ + title: 'Stop host', + message: 'Stop host ' + host.name + '? All applications on it will be stopped.', + confirmLabel: 'Stop', + danger: false, + }) + : true; + if (!ok) return; + try { + const res = await api('POST', '/api/hosts/' + encodeURIComponent(host.name) + '/' + action); + toast(res.message, 'ok'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + } + + async function removeHost(host) { + const ok = await confirm({ + title: 'Remove host', + message: 'Remove host ' + host.name + '? Applications on it will be undeployed (files are kept).', + confirmLabel: 'Remove', + danger: true, + requireText: host.name, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/hosts/' + encodeURIComponent(host.name)); + toast(res.message, 'ok'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + } + + async function persist() { + try { + const res = await api('POST', '/api/hosts/persist'); + toast(res.message, 'ok'); + } catch (err) { + toast(err.message, 'error'); + } + } + + function addHostModal() { + const body = el('div', {}, + el('div', { class: 'form-grid' }, + field('Name', el('input', { type: 'text', id: 'h-name', required: true, placeholder: 'localhost' })), + field('Aliases (comma separated)', el('input', { type: 'text', id: 'h-aliases', placeholder: 'example.com, www.example.com' })), + fieldSpan2('App base', el('input', { type: 'text', id: 'h-appbase', placeholder: '${catalina.base}/webapps' })), + field('Manager webapp', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-manager' }), + 'Deploy the manager webapp to this host')), + field('Auto deploy', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-autodeploy', checked: true }), + 'Auto deploy')), + field('Deploy on startup', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-deployonstartup', checked: true }), + 'Deploy on startup')), + field('Deploy XML', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-deployxml', checked: true }), + 'Deploy XML')), + field('Unpack WARs', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-unpackwars', checked: true }), + 'Unpack WARs')), + fieldSpan2('Copy XML', + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-copyxml' }), + 'Copy context XML from the deployed WAR into META-INF/context.xml')))); + + modal({ + title: 'Add virtual host', + content: body, + actions: [ + { label: 'Cancel' }, + { + label: 'Add', + class: 'btn-primary', + onClick: async (close) => { + const body_ = { + name: document.getElementById('h-name').value.trim(), + }; + if (!body_.name) { + toast('Enter a host name.', 'warn'); + return; + } + const aliases = document.getElementById('h-aliases').value.trim(); + if (aliases) body_.aliases = aliases.split(',').map((s) => s.trim()).filter(Boolean); + const appBase = document.getElementById('h-appbase').value.trim(); + if (appBase) body_.appBase = appBase; + body_.manager = document.getElementById('h-manager').checked; + body_.autoDeploy = document.getElementById('h-autodeploy').checked; + body_.deployOnStartup = document.getElementById('h-deployonstartup').checked; + body_.deployXML = document.getElementById('h-deployxml').checked; + body_.unpackWARs = document.getElementById('h-unpackwars').checked; + body_.copyXML = document.getElementById('h-copyxml').checked; + try { + const res = await api('POST', '/api/hosts', body_); + toast(res.message, 'ok'); + close(); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + } + + function field(label, input) { + return el('div', { class: 'field' }, el('label', {}, label), input); + } + + function fieldSpan2(label, input) { + return el('div', { class: 'field span-2' }, el('label', {}, label), input); + } + + await load(); + return null; +} diff --git a/modules/manager2/webapp/js/pages/logs.js b/modules/manager2/webapp/js/pages/logs.js new file mode 100644 index 000000000000..bd50bfe9effd --- /dev/null +++ b/modules/manager2/webapp/js/pages/logs.js @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { logPage } from '../logviewer.js'; + +export async function logs(container) { + return logPage(container, { + kind: 'log', + title: 'Logs', + subtitle: 'Server log files (JULI). Filter by severity and search the records.', + listUrl: '/api/logs', + fileUrl: '/api/logs/file', + }); +} diff --git a/modules/manager2/webapp/js/pages/monitoring.js b/modules/manager2/webapp/js/pages/monitoring.js new file mode 100644 index 000000000000..e0874da19977 --- /dev/null +++ b/modules/manager2/webapp/js/pages/monitoring.js @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { get } from '../api.js'; +import { el, clear, formatBytes, formatMs } from '../ui.js'; + +const POLL_MS = 5000; + +const STAGE_LABELS = { + P: 'Parsing request', + S: 'Service', + F: 'Finishing', + R: 'Ready', + K: 'Keep-alive', + '?': 'Unknown', +}; + +export async function monitoring(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, 'Monitoring'), + el('p', {}, 'Live worker (socket) table. Refreshes every 5 seconds; paused while the tab is hidden.'))); + container.append(view); + + const connectorsCard = el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), 'Connectors')), + el('div', { class: 'table-wrap' })); + const connectorsWrap = connectorsCard.querySelector('.table-wrap'); + view.append(connectorsCard); + + const workersCard = el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), 'Active workers'), + el('span', { id: 'worker-count', class: 'badge plain' })), + el('div', { class: 'table-wrap' })); + const workersWrap = workersCard.querySelector('.table-wrap'); + view.append(workersCard); + + let stopped = false; + + async function tick() { + if (stopped || document.hidden) return; + + let snap; + try { + snap = await get('/api/status'); + renderConnectors(snap.connectors); + } catch (err) { + return; + } + + let workers; + try { + workers = await get('/api/status/workers'); + } catch (err) { + return; + } + renderWorkers(workers); + } + + function renderConnectors(connectors) { + clear(connectorsWrap); + const rows = connectors.map((c) => el('tr', {}, + el('td', {}, el('strong', {}, c.name)), + el('td', { class: 'num' }, c.threads.busy + ' / ' + c.threads.current + ' / ' + c.threads.max), + el('td', { class: 'num' }, String(c.threads.keepAlive)), + c.requests + ? el('td', { class: 'num' }, formatMs(c.requests.processingTime) + ' (max ' + formatMs(c.requests.maxTime) + ')') + : el('td', {}, '-'), + el('td', { class: 'num' }, String(c.requests ? c.requests.count : '-')), + el('td', { class: 'num' }, String(c.requests ? c.requests.errors : '-')), + el('td', { class: 'num' }, c.requests ? formatBytes(c.requests.bytesReceived) : '-'), + el('td', { class: 'num' }, c.requests ? formatBytes(c.requests.bytesSent) : '-'))); + connectorsWrap.append(el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Connector'), + el('th', {}, 'Threads (busy / current / max)'), + el('th', {}, 'Keep-alive'), + el('th', {}, 'Processing time'), + el('th', {}, 'Requests'), + el('th', {}, 'Errors'), + el('th', {}, 'Bytes in'), + el('th', {}, 'Bytes out'))), + el('tbody', {}, rows.length > 0 ? rows + : el('tr', {}, el('td', { colspan: '8', class: 'empty' }, 'No connectors found'))))); + } + + function renderWorkers(workers) { + clear(workersWrap); + const countBadge = document.getElementById('worker-count'); + if (countBadge) { + countBadge.textContent = workers.length + ' active'; + } + const rows = workers.map((w) => el('tr', {}, + el('td', {}, + el('span', { class: 'badge ' + (w.stage === 'S' ? 'ok' : 'plain') }, + w.stage + (w.stage === 'S' ? ' · ' + (STAGE_LABELS[w.stage] || '') : ''))), + el('td', { class: 'num' }, w.time != null ? formatMs(w.time) : '-'), + el('td', { class: 'num' }, w.bytesSent != null ? formatBytes(w.bytesSent) : '-'), + el('td', { class: 'num' }, w.bytesReceived != null ? formatBytes(w.bytesReceived) : '-'), + el('td', {}, el('code', {}, + (w.remoteAddrForwarded ? w.remoteAddrForwarded + ' (' + w.remoteAddr + ')' : (w.remoteAddr || '-')))), + el('td', {}, w.virtualHost || '-'), + el('td', {}, + (w.method) + ? el('code', {}, w.method + ' ' + w.uri + (w.queryString ? '?' + w.queryString : '') + ' ' + w.protocol) + : '-'))); + workersWrap.append(el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Stage'), + el('th', {}, 'Time'), + el('th', {}, 'Sent'), + el('th', {}, 'Received'), + el('th', {}, 'Remote address'), + el('th', {}, 'Virtual host'), + el('th', {}, 'Request'))), + el('tbody', {}, rows.length > 0 ? rows + : el('tr', {}, el('td', { colspan: '7', class: 'empty' }, 'No active sockets'))))); + } + + const interval = setInterval(tick, POLL_MS); + await tick(); + + return () => { + stopped = true; + clearInterval(interval); + }; +} diff --git a/modules/manager2/webapp/js/pages/users.js b/modules/manager2/webapp/js/pages/users.js new file mode 100644 index 000000000000..5e85b172201c --- /dev/null +++ b/modules/manager2/webapp/js/pages/users.js @@ -0,0 +1,516 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { api, get } from '../api.js'; +import { el, clear, toast, table, modal, confirm, icon } from '../ui.js'; + +export async function users(container) { + let data = null; + let dbName = null; + let loading = false; + + const view = el('div', { class: 'users-page' }); + container.append(view); + + const canEdit = () => data && !data.readonly && data.writable; + + async function load() { + if (loading) return; + loading = true; + try { + const query = dbName ? '?name=' + encodeURIComponent(dbName) : ''; + data = await get('/api/users' + query); + render(); + } finally { + loading = false; + } + } + + function render() { + clear(view); + + view.append(header()); + if (data.readonly) { + view.append(el('div', { class: 'banner warn' }, + el('p', {}, + 'This user database is read-only. Add ', + el('code', {}, 'readonly="false"'), + ' to its ', + el('code', {}, 'Resource'), + ' definition in ', + el('code', {}, 'server.xml'), + ' and restart the server to allow changes.'))); + } + view.append(usersCard()); + view.append(groupsCard()); + view.append(rolesCard()); + } + + function header() { + const db = (data.databases || []).find((d) => d.name === data.name) || {}; + const badges = el('div', { class: 'row-actions' }, + el('span', { class: 'badge plain' }, db.type || 'UserDatabase')); + if (data.readonly) { + badges.append(el('span', { class: 'badge warn' }, 'read-only')); + } else if (data.writable === false) { + badges.append(el('span', { class: 'badge danger' }, 'not writable')); + } else { + badges.append(el('span', { class: 'badge ok' }, 'writable')); + } + const title = el('div', { class: 'card-title-row' }, + el('h2', {}, 'Users'), + badges); + + if ((data.databases || []).length > 1) { + const select = el('select', { + class: 'log-field', + 'aria-label': 'User database', + onchange: () => { + dbName = select.value; + load(); + }, + }, data.databases.map((d) => el('option', { value: d.name, selected: d.name === data.name || null }, + d.name))); + return el('div', { class: 'card' }, title, select); + } + const sub = el('p', { class: 'muted' }, + 'JNDI resource ', el('code', {}, data.name), + db.id ? ' (id ' + db.id + ')' : ''); + return el('div', { class: 'card' }, title, sub); + } + + function rolesBadges(roles, inherited) { + const node = el('div', { class: 'chips' }); + for (const role of roles || []) { + node.append(el('span', { class: 'badge plain' }, role)); + } + const extra = (inherited || []).filter((r) => !(roles || []).includes(r)); + if (extra.length > 0) { + node.append(el('span', { class: 'inherited', title: 'Inherited through group membership' }, + '+ ' + extra.join(', '))); + } + return node; + } + + function nameBadges(names) { + const node = el('div', { class: 'chips' }); + for (const name of names || []) { + node.append(el('span', { class: 'badge plain' }, name)); + } + if (!(names || []).length) { + node.append(el('span', { class: 'muted' }, '-')); + } + return node; + } + + function usersCard() { + const addBtn = el('button', { + type: 'button', + class: 'btn btn-primary btn-sm', + disabled: canEdit() ? null : true, + onclick: addUserModal, + }, icon('plus', 14), document.createTextNode(' Add user')); + + const t = table({ + columns: [ + { key: 'username', label: 'User', render: (u) => el('span', { class: 'user-cell' }, + el('span', {}, u.username), + u.fullName ? el('span', { class: 'muted' }, ' ' + u.fullName) : null) }, + { key: 'roles', label: 'Roles', render: (u) => rolesBadges(u.roles, u.effectiveRoles) }, + { key: 'groups', label: 'Groups', render: (u) => nameBadges(u.groups) }, + { + key: 'actions', label: '', render: (u) => el('div', { class: 'row-actions' }, + el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); rolesModal('user', u); } }, 'Roles'), + el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); passwordModal(u); } }, 'Password'), + el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); removeUser(u); } }, 'Remove')), + }, + ], + rows: data.users || [], + empty: 'No users in this database.', + }); + + const card = el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, 'Users'), addBtn), + t); + return card; + } + + function groupsCard() { + const addBtn = el('button', { + type: 'button', + class: 'btn btn-primary btn-sm', + disabled: canEdit() ? null : true, + onclick: addGroupModal, + }, icon('plus', 14), document.createTextNode(' Add group')); + + const t = table({ + columns: [ + { key: 'groupname', label: 'Group', render: (g) => el('span', {}, g.groupname) }, + { key: 'roles', label: 'Roles', render: (g) => rolesBadges(g.roles, null) }, + { key: 'members', label: 'Members', render: (g) => nameBadges(g.members) }, + { + key: 'actions', label: '', render: (g) => el('div', { class: 'row-actions' }, + el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); membersModal(g); } }, 'Members'), + el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); rolesModal('group', g); } }, 'Roles'), + el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); removeGroup(g); } }, 'Remove')), + }, + ], + rows: data.groups || [], + empty: 'No groups in this database.', + }); + + return el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, 'Groups'), addBtn), + t); + } + + function rolesCard() { + const addBtn = el('button', { + type: 'button', + class: 'btn btn-primary btn-sm', + disabled: canEdit() ? null : true, + onclick: addRoleModal, + }, icon('plus', 14), document.createTextNode(' Add role')); + + const t = table({ + columns: [ + { key: 'rolename', label: 'Role', render: (r) => el('span', { class: 'badge plain' }, r.rolename) }, + { key: 'description', label: 'Description', render: (r) => r.description + ? el('span', {}, r.description) + : el('span', { class: 'muted' }, '-') }, + { key: 'users', label: 'Users', render: (r) => nameBadges((data.users || []) + .filter((u) => (u.roles || []).includes(r.rolename)) + .map((u) => u.username)) }, + { key: 'groups', label: 'Groups', render: (r) => nameBadges((data.groups || []) + .filter((g) => (g.roles || []).includes(r.rolename)) + .map((g) => g.groupname)) }, + { + key: 'actions', label: '', render: (r) => el('div', { class: 'row-actions' }, + el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, + onclick: (e) => { e.stopPropagation(); removeRole(r); } }, 'Remove')), + }, + ], + rows: data.roles || [], + empty: 'No roles defined in this database.', + }); + + return el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, 'Roles'), addBtn), + el('p', { class: 'muted' }, + 'Roles can also be created implicitly when assigned to a user or group.'), + t); + } + + // ------------------------------- Modals --------------------------------- + + function listInput(label, id, values, suggestions, placeholder) { + const list = el('datalist', { id }); + for (const s of (suggestions || [])) { + list.append(el('option', { value: s })); + } + const input = el('input', { type: 'text', list: id, placeholder: placeholder || '' }); + input.value = (values || []).join(', '); + return el('div', { class: 'field' }, el('label', {}, label), input, list); + } + + function parseList(value) { + return String(value || '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + + function roleNames() { + return (data.roles || []).map((r) => r.rolename); + } + + function userNames() { + return (data.users || []).map((u) => u.username); + } + + function addUserModal() { + const username = el('input', { type: 'text', autocomplete: 'off' }); + const password = el('input', { type: 'password', autocomplete: 'new-password' }); + const fullName = el('input', { type: 'text', autocomplete: 'off' }); + const roles = listInput('Roles (comma separated)', 'ud-roles', [], roleNames()); + const rolesInput = roles.querySelector('input'); + + modal({ + title: 'Add user', + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, 'User name'), username), + el('div', { class: 'field' }, el('label', {}, 'Password'), password), + el('div', { class: 'field' }, el('label', {}, 'Full name (optional)'), fullName), + roles), + actions: [ + { label: 'Cancel' }, + { + label: 'Add user', + class: 'btn-primary', + onClick: async (close) => { + const body = { + username: username.value.trim(), + password: password.value, + }; + if (fullName.value.trim()) body.fullName = fullName.value.trim(); + const r = parseList(rolesInput.value); + if (r.length) body.roles = r; + try { + const res = await api('POST', '/api/users', body); + close(); + toast(res.message || 'User added.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + username.focus(); + } + + function rolesModal(kind, item) { + const name = kind === 'user' ? item.username : item.groupname; + const field = kind === 'user' ? '/api/users/' + encodeURIComponent(name) : '/api/groups/' + encodeURIComponent(name); + const list = listInput('Roles (comma separated)', 'roles-edit', item.roles, roleNames()); + const input = list.querySelector('input'); + + modal({ + title: 'Roles for ' + name, + content: el('div', {}, list), + actions: [ + { label: 'Cancel' }, + { + label: 'Save', + class: 'btn-primary', + onClick: async (close) => { + try { + const res = await api('POST', field + '/roles', { roles: parseList(input.value) }); + close(); + toast(res.message || 'Roles saved.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + input.focus(); + } + + function passwordModal(user) { + const password = el('input', { type: 'password', autocomplete: 'new-password' }); + modal({ + title: 'Password for ' + user.username, + content: el('div', {}, + el('div', { class: 'field' }, + el('label', {}, 'New password'), + password, + el('span', { class: 'hint' }, 'Stored with the same semantics as the password attribute of tomcat-users.xml.'))), + actions: [ + { label: 'Cancel' }, + { + label: 'Save', + class: 'btn-primary', + onClick: async (close) => { + try { + const res = await api('POST', '/api/users/' + encodeURIComponent(user.username) + '/password', + { password: password.value }); + close(); + toast(res.message || 'Password saved.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + password.focus(); + } + + async function removeUser(user) { + const ok = await confirm({ + title: 'Remove user', + message: 'Remove the user "' + user.username + '" and all of its roles and group memberships?', + confirmLabel: 'Remove user', + danger: true, + requireText: user.username, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/users/' + encodeURIComponent(user.username)); + toast(res.message || 'User removed.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + } + + function addGroupModal() { + const groupname = el('input', { type: 'text', autocomplete: 'off' }); + const description = el('input', { type: 'text', autocomplete: 'off' }); + const roles = listInput('Roles (comma separated)', 'ug-roles', [], roleNames()); + const rolesInput = roles.querySelector('input'); + + modal({ + title: 'Add group', + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, 'Group name'), groupname), + el('div', { class: 'field' }, el('label', {}, 'Description (optional)'), description), + roles), + actions: [ + { label: 'Cancel' }, + { + label: 'Add group', + class: 'btn-primary', + onClick: async (close) => { + const body = { groupname: groupname.value.trim() }; + if (description.value.trim()) body.description = description.value.trim(); + const r = parseList(rolesInput.value); + if (r.length) body.roles = r; + try { + const res = await api('POST', '/api/groups', body); + close(); + toast(res.message || 'Group added.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + groupname.focus(); + } + + function membersModal(group) { + const list = listInput('Members (comma separated)', 'members-edit', group.members, userNames()); + const input = list.querySelector('input'); + + modal({ + title: 'Members of ' + group.groupname, + content: el('div', {}, list), + actions: [ + { label: 'Cancel' }, + { + label: 'Save', + class: 'btn-primary', + onClick: async (close) => { + try { + const res = await api('POST', '/api/groups/' + encodeURIComponent(group.groupname) + '/members', + { members: parseList(input.value) }); + close(); + toast(res.message || 'Members saved.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + input.focus(); + } + + async function removeGroup(group) { + const ok = await confirm({ + title: 'Remove group', + message: 'Remove the group "' + group.groupname + '" and its membership from all users?', + confirmLabel: 'Remove group', + danger: true, + requireText: group.groupname, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/groups/' + encodeURIComponent(group.groupname)); + toast(res.message || 'Group removed.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + } + + function addRoleModal() { + const rolename = el('input', { type: 'text', autocomplete: 'off' }); + const description = el('input', { type: 'text', autocomplete: 'off' }); + + modal({ + title: 'Add role', + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, 'Role name'), rolename), + el('div', { class: 'field' }, el('label', {}, 'Description (optional)'), description)), + actions: [ + { label: 'Cancel' }, + { + label: 'Add role', + class: 'btn-primary', + onClick: async (close) => { + const body = { rolename: rolename.value.trim() }; + if (description.value.trim()) body.description = description.value.trim(); + try { + const res = await api('POST', '/api/roles', body); + close(); + toast(res.message || 'Role added.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + rolename.focus(); + } + + async function removeRole(role) { + const ok = await confirm({ + title: 'Remove role', + message: 'Remove the role "' + role.rolename + '"? It will be detached from all users and groups that hold it.', + confirmLabel: 'Remove role', + danger: true, + requireText: role.rolename, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/roles/' + encodeURIComponent(role.rolename)); + toast(res.message || 'Role removed.'); + load(); + } catch (err) { + toast(err.message, 'error'); + } + } + + try { + await load(); + } catch (err) { + if (err.code === 'USER_DATABASE_MISSING') { + clear(view); + view.append(el('div', { class: 'card empty' }, el('p', {}, err.message))); + return; + } + throw err; + } +} diff --git a/modules/manager2/webapp/js/router.js b/modules/manager2/webapp/js/router.js new file mode 100644 index 000000000000..c11f656e4b71 --- /dev/null +++ b/modules/manager2/webapp/js/router.js @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +// A minimal history-based router. Routes are registered in priority order; +// the first matcher wins. Matchers may return a boolean (handled) or an +// async result. + +const routes = []; +let notFoundHandler = null; + +// Base path of the web application, derived from the location of this +// script so the webapp can be deployed under any context path. +const BASE = new URL('.', import.meta.url).pathname.replace(/js\/$/, '').replace(/\/$/, ''); + +function currentPath() { + const path = window.location.pathname; + let p = path.startsWith(BASE) ? path.substring(BASE.length) : path; + if (!p.startsWith('/')) p = '/' + p; + if (p === '/index.html') { + p = '/'; + } + return p.replace(/\/+$/, '') || '/'; +} + +/** + * Register a route. + * + * @param {string} pattern path pattern with {param} placeholders + * @param {function} handler (params, path) => void + */ +export function register(pattern, handler) { + const names = []; + const regex = new RegExp('^' + pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\\{([a-zA-Z0-9_]+)\\}/g, (_, n) => { + names.push(n); + return '([^/]+)'; + }) + '$'); + routes.push({ regex, names, handler }); +} + +export function setNotFound(handler) { + notFoundHandler = handler; +} + +export async function render() { + const path = currentPath(); + for (const route of routes) { + const m = path.match(route.regex); + if (m) { + const params = {}; + route.names.forEach((n, i) => { + params[n] = decodeURIComponent(m[i + 1]); + }); + await route.handler(params, path); + updateNav(path); + return; + } + } + if (notFoundHandler) { + await notFoundHandler(path); + } + updateNav(path); +} + +export function navigate(path) { + window.history.pushState({}, '', BASE + path); + render(); +} + +export function currentRoute() { + return currentPath(); +} + +let navUpdater = null; +export function setNavUpdater(fn) { + navUpdater = fn; +} + +function updateNav(path) { + if (navUpdater) { + navUpdater(path); + } +} + +window.addEventListener('popstate', () => { + render(); +}); diff --git a/modules/manager2/webapp/js/ui.js b/modules/manager2/webapp/js/ui.js new file mode 100644 index 000000000000..b7b23b26765e --- /dev/null +++ b/modules/manager2/webapp/js/ui.js @@ -0,0 +1,356 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +// ============================ DOM helpers ============================ + +/** + * Create an element. + * + * @param {string} tag + * @param {object} [attrs] attributes/event handlers (on* keys are events) + * @param {...(Node|string)} children + */ +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) { + continue; + } + if (key.startsWith('on') && typeof value === 'function') { + node.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === 'class') { + node.className = value; + } else if (key === 'style') { + // Set through the CSS object, not setAttribute: the web app's + // Content-Security-Policy (style-src 'self', no 'unsafe-inline') + // blocks style attributes applied that way. + node.style.cssText = value; + } else if (key === 'dataset') { + Object.assign(node.dataset, value); + } else if (key === 'text') { + node.textContent = value; + } else if (key === 'html') { + node.innerHTML = value; // only ever used for trusted static markup + } else if (value === true) { + node.setAttribute(key, ''); + } else { + node.setAttribute(key, String(value)); + } + } + for (const child of children.flat(Infinity)) { + if (child === null || child === undefined || child === false) { + continue; + } + node.append(child.nodeType ? child : document.createTextNode(String(child))); + } + return node; +} + +export function clear(node) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } + return node; +} + +// ============================ Formatting =============================== + +export function formatBytes(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + const abs = Math.abs(n); + if (abs < 1024) return n + ' B'; + if (abs < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB'; + if (abs < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MiB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GiB'; +} + +export function formatRate(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + return n.toFixed(1) + '/s'; +} + +export function formatMs(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + if (n < 1000) return Math.round(n) + ' ms'; + return (n / 1000).toFixed(2) + ' s'; +} + +export function formatSeconds(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + if (n < 60) return Math.round(n) + ' s'; + if (n < 3600) return Math.floor(n / 60) + ' min ' + Math.round(n % 60) + ' s'; + return Math.floor(n / 3600) + ' h ' + Math.floor((n % 3600) / 60) + ' min'; +} + +export function formatDuration(ms) { + if (ms === null || ms === undefined || isNaN(ms)) return '-'; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return d + ' d ' + h + ' h'; + if (h > 0) return h + ' h ' + m + ' min'; + if (m > 0) return m + ' min'; + return Math.max(0, s) + ' s'; +} + +export function formatTimestamp(ts) { + if (!ts) return '-'; + return new Date(ts).toLocaleString(); +} + +export function formatTime(ts) { + if (!ts) return '-'; + return new Date(ts).toLocaleTimeString(); +} + +// ============================ State badges ============================= + +export function stateBadge(state) { + // The context state reported by the API is "STARTED" (or "STOPPED"); + // "RUNNABLE" is accepted for compatibility with callers that normalize + // the available flag themselves. + const running = state === 'RUNNABLE' || state === 'STARTED' || state === 'AVAILABLE'; + return el('span', { class: 'badge ' + (running ? 'ok' : 'stop') }, running ? 'Running' : 'Stopped'); +} + +// ============================ Toasts =================================== + +let toastTimer = 0; + +export function toast(message, type = 'info', timeout = 5000) { + const region = document.getElementById('toast-region'); + const node = el('div', { class: 'toast ' + type }, + el('div', { class: 'toast-msg' }, message)); + region.append(node); + setTimeout(() => { + node.remove(); + }, timeout); +} + +// ============================ Modal / confirm ========================== + +/** + * Show a modal. Returns a close function. + * + * @param {object} opts { title, content (node or fn), actions (array of + * {label, class, onClick}), wide (boolean) } + */ +export function modal({ title, content, actions = [], wide = false, onClose = null }) { + const root = document.getElementById('modal-root'); + const close = () => { + backdrop.remove(); + document.removeEventListener('keydown', onKey, true); + if (onClose) onClose(); + }; + const onKey = (e) => { + if (e.key === 'Escape') close(); + }; + + const actionRow = el('div', { class: 'modal-actions' }, + actions.map((a) => el('button', { + type: 'button', + class: 'btn ' + (a.class || ''), + onclick: () => { + if (a.onClick) a.onClick(close); + else close(); + }, + }, a.label))); + + const body = typeof content === 'function' ? content() : content; + const box = el('div', { class: 'modal' + (wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true' }, + el('h2', {}, title), + body, + actionRow); + + const backdrop = el('div', { class: 'modal-backdrop', onclick: (e) => { + if (e.target === backdrop) close(); + } }, box); + root.append(backdrop); + document.addEventListener('keydown', onKey, true); + const firstButton = box.querySelector('button, input, select, textarea'); + if (firstButton) firstButton.focus(); + return close; +} + +/** + * Confirmation dialog. Resolves with true/false. + * + * @param {object} opts { title, message (string|node), confirmLabel, + * danger (boolean), requireText (string - user must type it) } + */ +export function confirm(opts) { + return new Promise((resolve) => { + let done = false; + const finish = (value) => { + if (done) return; + done = true; + close(); + resolve(value); + }; + const content = el('div', {}, + typeof opts.message === 'string' + ? el('p', { style: 'margin-top:0;color:var(--text-soft);' }, opts.message) + : opts.message); + let input = null; + if (opts.requireText) { + input = el('div', { class: 'field', style: 'margin-top:16px;' }, + el('label', {}, 'Type ', el('code', {}, opts.requireText), ' to confirm'), + el('input', { type: 'text', autocomplete: 'off' })); + content.append(input); + } + const close = modal({ + title: opts.title, + content, + actions: [ + { label: 'Cancel', onClick: () => finish(false) }, + { + label: opts.confirmLabel || 'Confirm', + class: opts.danger ? 'btn-danger' : 'btn-primary', + onClick: () => { + if (input) { + const value = input.querySelector('input').value; + if (value !== opts.requireText) { + input.querySelector('input').focus(); + return; + } + } + finish(true); + }, + }, + ], + // Dismissal via backdrop or Escape + onClose: () => finish(false), + }); + }); +} + +// ============================ Drawer =================================== + +/** + * Show a right-hand drawer. Returns a close function. + */ +export function drawer({ title, content, onClose = null }) { + const close = () => { + backdrop.remove(); + panel.remove(); + document.removeEventListener('keydown', onKey, true); + if (onClose) onClose(); + }; + const onKey = (e) => { + if (e.key === 'Escape') close(); + }; + + const panel = el('aside', { class: 'drawer', role: 'dialog', 'aria-modal': 'true' }, + el('div', { class: 'drawer-head' }, + el('h3', {}, title), + el('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Close', onclick: close }, + el('span', { html: '×', style: 'font-size:20px;line-height:1;' }))), + el('div', { class: 'drawer-body' }, content)); + + const backdrop = el('div', { class: 'drawer-backdrop', onclick: close }); + document.body.append(backdrop, panel); + document.addEventListener('keydown', onKey, true); + const firstButton = panel.querySelector('button, input, select, textarea'); + if (firstButton) firstButton.focus(); + return close; +} + +// ============================ Table ==================================== + +/** + * Build a data table. + * + * @param {object} opts { columns: [{key, label, sortable, render, numeric}], + * rows: [object], sortKey, sortAsc, onSort(key), onRowClick(row), + * empty (string) } + */ +export function table(opts) { + const { columns, rows, sortKey = null, sortAsc = true, onSort, onRowClick, empty = 'No data' } = opts; + + const thead = el('tr', {}, columns.map((c) => { + const label = c.sortable + ? c.label + (sortKey === c.key ? (sortAsc ? ' \u25B2' : ' \u25BC') : '') + : c.label; + return el('th', { + class: (c.sortable ? 'sortable' : '') + (c.numeric ? ' num' : ''), + role: c.sortable ? 'button' : null, + onclick: c.sortable && onSort ? () => onSort(c.key) : null, + }, label); + })); + + const tbody = el('tbody', {}, rows.length === 0 + ? el('tr', {}, el('td', { colspan: columns.length, class: 'empty' }, empty)) + : rows.map((row) => el('tr', { + onclick: onRowClick ? () => onRowClick(row) : null, + style: onRowClick ? 'cursor:pointer' : null, + }, columns.map((c) => { + const value = c.render ? c.render(row) : row[c.key]; + const node = el('td', { class: c.numeric ? 'num' : (c.muted ? 'muted' : '') }); + if (value === null || value === undefined) { + node.append(document.createTextNode('-')); + } else if (value.nodeType) { + node.append(value); + } else { + node.append(document.createTextNode(String(value))); + } + return node; + })))); + + return el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, el('thead', {}, thead), tbody)); +} + +// ============================ Icons ==================================== + +const ICONS = { + dashboard: 'M3 3h8v8H3V3Zm10 0h8v5h-8V3Zm0 7h8v11h-8V10ZM3 13h8v8H3v-8Z', + apps: 'M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 3.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 0 0-7 0Z', + hosts: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 2c1.9 0 3.7.6 5.2 1.6-.3 1.9-1.1 3.6-2.3 4.9-1.9-.3-3.8-.3-5.6 0-1.3-1.3-2.1-3-2.4-4.9A9.9 9.9 0 0 1 12 4Zm-7.7 4.4c.7 2.7 2.1 5 4 6.7-.3 1.9-.9 3.6-1.8 5.1A8 8 0 0 1 4.3 8.4Zm7.7 12.1c-.7 0-1.4-.1-2.1-.2.9-1.6 1.6-3.4 1.9-5.3 1.3-.2 2.7-.2 4 0 .3 1.9 1 3.7 1.9 5.3-.7.1-1.4.2-2.1.2h-3.6Zm7.7-1.8c-.9-1.5-1.5-3.2-1.8-5.1 1.9-1.7 3.3-4 4-6.7a8 8 0 0 1-2.2 11.8Z', + monitoring: 'M3 13h4l3-8 4 14 3-8h4v2h-2.5l-4.5 10-4-14-2 8H3v-2Z', + diagnostics: 'M10.5 2h3a1 1 0 0 1 1 .9l.2 2.1 2 .7 1.5-1.6a1 1 0 0 1 1.3-.2l2.1 1.5a1 1 0 0 1 .3 1.3l-1.6 1.6.7 2 2.1.2a1 1 0 0 1 .9 1v3a1 1 0 0 1-.9 1l-2.1.2-.7 2 1.6 1.6a1 1 0 0 1 .2 1.3l-1.5 2.1a1 1 0 0 1-1.3.2l-1.6-1.6-2 .7-.2 2.1a1 1 0 0 1-1 .9h-3a1 1 0 0 1-1-.9l-.2-2.1-2-.7-1.5 1.6a1 1 0 0 1-1.3.2l-2.1-1.5a1 1 0 0 1-.3-1.3l1.6-1.6-.7-2-2.1-.2a1 1 0 0 1-.9-1v-3a1 1 0 0 1 .9-1l2.1-.2.7-2-1.6-1.6a1 1 0 0 1-.2-1.3l1.5-2.1a1 1 0 0 1 1.3-.2l1.6 1.6 2-.7.2-2.1a1 1 0 0 1 1-.9Zm1.5 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z', + sun: 'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm0-5h.01L13 4h-2l1-2Zm0 20h.01L13 20h-2l1 2ZM2 12l2 1v-2L2 12Zm20 0v.01L22 12l-2 1v-2l2 1ZM4.9 4.9 6.3 6.3 4.9 4.9Zm14.2 14.2-1.4-1.4 1.4 1.4ZM4.9 19.1l1.4-1.4-1.4 1.4ZM19.1 4.9l-1.4 1.4 1.4-1.4Z', + moon: 'M12 3a9 9 0 1 0 9 9c0-.5 0-1-.1-1.4A5.4 5.4 0 0 1 12 3Z', + logout: 'M16 13v-2H7V8l-5 4 5 4v-3h9Zm3-10H11a2 2 0 0 0-2 2v3h2V5h8v14h-8v-3H9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Z', + upload: 'M12 3 4 9h5v6h6V9h5l-8-6Zm-8 16h16v2H4v-2Z', + play: 'M8 5v14l11-7L8 5Z', + stop: 'M6 6h12v12H6V6Z', + reload: 'M17.65 6.35A8 8 0 1 0 19.7 14h-2.1a6 6 0 1 1-1.4-6.2L13 11h7V4l-2.35 2.35Z', + trash: 'M6 7h12l-1 14H7L6 7Zm3-4h6l1 2h4v2H4V5h4l1-2Z', + plus: 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z', + logs: 'M4 4h16v2.5H4V4Zm0 4.75h16v2.5H4v-2.5ZM4 13.5h10v2.5H4v-2.5Zm0 4.75h16v2.5H4v-2.5Z', + 'access-log': 'M3 5h3.5v3H3V5Zm5.5 0H21v3H8.5V5ZM3 10.5h3.5v3H3v-3Zm5.5 0H21v3H8.5v-3ZM3 16h3.5v3H3v-3Zm5.5 0H21v3H8.5v-3Z', + users: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z', + close: 'M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z', + config: 'M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h3v3h3v-3h3v3h-3v3h3v3h-3v-3h-3v3h-3v-3h3v-3h-3Z', +}; + +export function icon(name, size = 18) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', ICONS[name] || ''); + svg.append(path); + return svg; +} + +export function svgPath(name) { + return ICONS[name] || ''; +} diff --git a/modules/manager2/webapp/login.html b/modules/manager2/webapp/login.html new file mode 100644 index 000000000000..974e19ed7d7b --- /dev/null +++ b/modules/manager2/webapp/login.html @@ -0,0 +1,55 @@ + + + + + + + + Log in · Tomcat Manager + + + + + +

+ + + + +
+ + diff --git a/ssl.patch b/ssl.patch new file mode 100644 index 000000000000..a0758727136c --- /dev/null +++ b/ssl.patch @@ -0,0 +1,34 @@ +diff --git a/java/org/apache/tomcat/util/net/AbstractEndpoint.java b/java/org/apache/tomcat/util/net/AbstractEndpoint.java +index b0fe7ff28e..158302b320 100644 +--- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java ++++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java +@@ -499,7 +499,12 @@ public abstract class AbstractEndpoint { + // internally because they are used as keys in a ConcurrentMap where + // keys are compared in a case-sensitive manner. + String hostNameLower = hostName.toLowerCase(Locale.ENGLISH); +- if (hostNameLower.equals(getDefaultSSLHostConfigName())) { ++ // The default host configuration is the fallback for handshakes ++ // without a matching SNI name, so it cannot be removed while the ++ // endpoint is still serving TLS. Once TLS is switched off (for ++ // example to remove the last remaining host configuration) the ++ // guard no longer applies. ++ if (isSSLEnabled() && hostNameLower.equals(getDefaultSSLHostConfigName())) { + throw new IllegalArgumentException(sm.getString("endpoint.removeDefaultSslHostConfig", hostName)); + } + SSLHostConfig sslHostConfig = sslHostConfigs.remove(hostNameLower); +@@ -827,7 +832,14 @@ public abstract class AbstractEndpoint { + * + * @throws Exception If an error occurs while initializing SSL + */ +- protected void initialiseSsl() throws Exception { ++ /** ++ * Initialize the SSL implementation and (re-)create the SSL context ++ * of every SSL host configuration. Called from {@code bind()} but ++ * also made available to components that switch an already bound, ++ * running endpoint to TLS after the initial bind (which is when the ++ * SSL implementation and contexts are validated and created). ++ */ ++ public void initialiseSsl() throws Exception { + if (isSSLEnabled()) { + sslImplementation = SSLImplementation.getInstance(getSslImplementationName()); + From 2a7d10c3eca4c41218f485e3ae38ecf8664aca85 Mon Sep 17 00:00:00 2001 From: remm Date: Tue, 15 Sep 2026 16:52:03 +0200 Subject: [PATCH 02/17] Add remove upgrade protocol --- .../org/apache/coyote/http11/AbstractHttp11Protocol.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java index 3bc72c0c5276..b7d45608ce3a 100644 --- a/java/org/apache/coyote/http11/AbstractHttp11Protocol.java +++ b/java/org/apache/coyote/http11/AbstractHttp11Protocol.java @@ -794,6 +794,15 @@ public void addUpgradeProtocol(UpgradeProtocol upgradeProtocol) { upgradeProtocols.add(upgradeProtocol); } + /** + * Remove specified upgrade protocol. + * @param upgradeProtocol the upgrade protocol + * @return true if the protocol was removed + */ + public boolean removeUpgradeProtocol(UpgradeProtocol upgradeProtocol) { + return upgradeProtocols.remove(upgradeProtocol); + } + @Override public UpgradeProtocol[] findUpgradeProtocols() { return upgradeProtocols.toArray(new UpgradeProtocol[0]); From 36aa7f5c70e2ea28da4acfc5a7882cb78168be4d Mon Sep 17 00:00:00 2001 From: remm Date: Tue, 15 Sep 2026 18:11:21 +0200 Subject: [PATCH 03/17] Add lifecycle operations to the component tree --- modules/manager2/manager2-design.md | 22 ++- .../tomcat/manager2/ConfigApiServlet.java | 150 ++++++++++++++++- .../tomcat/manager2/LocalStrings.properties | 11 +- .../tomcat/manager2/TestManager2Config.java | 159 +++++++++++++++++- modules/manager2/webapp/css/manager2.css | 1 + .../manager2/webapp/js/pages/configuration.js | 130 +++++++++++++- 6 files changed, 465 insertions(+), 8 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 497161e373a1..499d696f08c8 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -85,7 +85,7 @@ in manager2, mapped as follows: | Feature | manager2 page / API | |---|---| - | Browse and edit the whole live server configuration (services, engines, hosts, contexts, wrappers, valves, connectors, executors, aliases, lifecycle listeners, realms with sub realms, the context sub components — manager with its session id generator, resources, loader, cookie processor — the cluster and its full channel (membership, sender + transport, receiver, interceptors, deployer, manager template, cluster valves and listeners) on any container, the JNDI naming resources of the server and of each context, and TLS: SSL host configurations with their certificates): property editing, structural add/remove, persistence to `server.xml` | Configuration page / `GET /api/config/*` (the legacy manager required hand-editing `server.xml` and a restart for anything beyond the host manager's "persist" button; TLS, realms and JNDI entries apply live, without a restart) | + | Browse and edit the whole live server configuration (services, engines, hosts, contexts, wrappers, valves, connectors, executors, aliases, lifecycle listeners, realms with sub realms, the context sub components — manager with its session id generator, resources, loader, cookie processor — the cluster and its full channel (membership, sender + transport, receiver, interceptors, deployer, manager template, cluster valves and listeners) on any container, the JNDI naming resources of the server and of each context, and TLS: SSL host configurations with their certificates): property editing, structural add/remove, start/stop/restart of any component with a lifecycle, persistence to `server.xml` | Configuration page / `GET /api/config/*` (the legacy manager required hand-editing `server.xml` and a restart for anything beyond the host manager's "persist" button; TLS, realms and JNDI entries apply live, without a restart) | ## 3. Architecture @@ -218,11 +218,12 @@ machine-readable `error` code and non-2xx status. | POST | `/api/roles` | manager-gui | `{"rolename", "description"?, "name"?}` create a role (409 when it exists) | | DELETE | `/api/roles/{rolename}?name=` | manager-gui | remove a role and detach it from all users and groups (404 when absent; 400 `SELF_ROLE_REMOVAL` when the signed-in account holds the role, directly or through a group) | | GET | `/api/config/tree` | manager-gui | the live component tree below `Server`: `{"tree": {id, type, className, name, state?, self?, children[]}}`; `self: true` on the context hosting this webapp; a context carries its `manager` (with the manager's `sessionIdGenerator`), `resources`, `loader` and `cookieProcessor` as children (a running context always has all of them); the `Server` and each context carry a single `namingResources` node (the `NamingResourcesImpl`) whose children are the JNDI entries, keyed by JNDI name — `resource`, `resourceLink` (context only), `resourceEnvRef`, `environment`, `ejb`, `localEjb`, `serviceRef`; an engine, host or context that owns a cluster carries a single `cluster` child (an inherited parent cluster is not a child) whose children are the `channel` (holding `membership`, `sender` — with a `transport` child for a replication transmitter —, `receiver` and the repeatable `interceptor` nodes), the repeatable `clusterValve`, the `clusterManager` (with its `sessionIdGenerator`), the repeatable `clusterListener` and the repeatable `listener` | -| GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description`, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | +| GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `lifecycle` (whether the component implements `Lifecycle` and therefore supports the start/stop/restart operations), `affectsSelf` (whether a start or stop of the component would interrupt access to this web application itself — the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context; only a restart is allowed for it), `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description`, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | | POST | `/api/config/attribute` | manager-gui | `{"id", "name", "value", "confirm"?}` — set one writable property on the live component (the change takes effect immediately); `name`/`path`/`defaultHost` additionally require `confirm` to equal the current value; a TLS attribute of a running, TLS enabled connector is re-validated by reloading the affected host configuration and the previous value is restored (400 `UPDATE_FAILED`) when the new value does not validate. For a JNDI entry, editing an attribute (or a `param` — an empty value removes it) is applied by removing and re-adding the entry (the `NamingContextListener` reacts to the property-change events to rebind the live JNDI environment); the previous state is restored (400 `UPDATE_FAILED`) when the re-add fails (e.g. the new JNDI name is already in use) | | POST | `/api/config/child` | manager-gui | `{"parent", "type", ...}` — add a `service` (created together with an engine of the same name), `host`, `context` (docBase auto-created), `wrapper`, `valve`, `connector`, `executor`, `alias`, `realm` (any container, or a `CombinedRealm` for a sub realm; instantiated from `className`), a context sub component — `manager`, `resources`, `loader` or `cookieProcessor` (parent: a context) or `sessionIdGenerator` (parent: a manager; all instantiated from `className` and **replacing** the current instance, since a context/manager holds exactly one of each) — or `listener` (any parent whose component implements `Lifecycle`; instantiated from `className` and registered, not started); a `cluster` (parent: an engine, host or context) is instantiated from `className` (default `SimpleTcpCluster`) and attached via `setCluster`, which starts the channel and applies the cluster defaults (the addition is rolled back + 400 `START_FAILED` when it cannot start), and its sub components are added by `className` with a sensible default — `channel` (default `GroupChannel`, replacing the current one), `membership` (default `McastService`, parent: the channel), `sender` (default `ReplicationTransmitter`, parent: the channel), `receiver` (default `NioReceiver`, parent: the channel), `interceptor` (parent: the channel, repeatable), `clusterValve` (a `Valve` that is a `ClusterValve`, parent: the cluster, repeatable; 400 `INVALID_CLASS` otherwise), `deployer` (default `FarmWarDeployer`, parent: the cluster), `clusterManager` (default `DeltaManager`, parent: the cluster), `transport` (default `PooledParallelSender`, parent: the sender) and `clusterListener` (parent: the cluster, repeatable); the single-valued slots replace the current instance; structural components are started immediately and the addition rolled back when the start fails. Replacing a context's `manager` or `loader` on a running context stops the old instance and starts the new one (rolled back + 400 `START_FAILED` when it does not start); replacing the `resources` of a running context is refused (400 `CONTEXT_RUNNING` — stop the context first); replacing the `manager` or `loader` of this webapp's own context is refused (403 `SELF_COMPONENT` — it would destroy the admin session / the running classes). A `sslHostConfig` (parent: a connector) optionally carries an initial `certificate` object; on a running connector the TLS configuration is validated and applied without a restart (400 `ADD_FAILED` + rollback otherwise); the first certificate is required for a running connector (400 `INVALID_VALUE`). A further `certificate` is added with the crypto type in the `type` field (`RSA`, `DSA`, ...). A JNDI entry (`parent`: a `namingResources` node) — `resource`, `resourceLink` (refused at the server level, 400 `BAD_PARENT`), `resourceEnvRef`, `environment`, `ejb`, `localEjb` or `serviceRef` — requires `name` and `jndiType`; a `resourceLink` additionally requires `global`; a `factory` (a `resource` parameter / `resourceLink` attribute) must be loadable (400 `INVALID_CLASS`); a `params` object carries the entry's generic string parameters (the `ResourceBase` property map) for any entry type — validated against the closed option set of a first-party factory for a `resource`, free-form otherwise; a JNDI name already in use is 409 `DUPLICATE`, a missing `jndiType` 400 `MISSING_FIELD`; the entry is registered and bound in the live JNDI environment at once | | DELETE | `/api/config/child` | manager-gui | `{"id", "confirm"?}` — remove a component (containers are stopped recursively); `confirm` must equal the component's display name for `host`/`context`/`service`/`engine`/`connector`/`executor`/`wrapper`/`valve`/`sslHostConfig`/`realm`/`cluster` and for the JNDI entries (`resource`/`resourceLink`/`resourceEnvRef`/`environment`/`ejb`/`localEjb`/`serviceRef`, whose JNDI name is unbound from the live JNDI environment); 400 `LAST_SERVICE`, 403 `SELF_COMPONENT`, 400 `BASIC_COMPONENT`, 400 `NOT_EMPTY`, 400 `LAST_REALM` (the container would be left without a realm), 400 `REQUIRED_COMPONENT` (the context's `manager`/`resources`/`loader`/`cookieProcessor`, a manager's `sessionIdGenerator` and the `namingResources` node itself are required and cannot be removed), 400 `SSL_DEFAULT` and 400 `SSL_LAST_CERTIFICATE` guards apply; removing the last SSL host configuration of a running, TLS enabled connector switches it back to plain HTTP; a `cluster` (which stops the cluster and its channel) and its single-valued children (`channel`, `membership`, `sender`, `receiver`, `deployer`, `clusterManager`, `transport`, `clusterListener` and the static `member`) can be detached from their parent, but a `clusterValve` or `interceptor` has no removal API on a running cluster and is refused (400 `REMOVE_NOT_SUPPORTED`) | - | GET | `/api/config/store/preview` | manager-gui | `{"xml", "files", "restartsManager"}` — the resulting `server.xml`, the external context files that would be rewritten, and whether the save restarts the manager; read-only, the external files are captured in memory and nothing is written | + | POST | `/api/config/lifecycle` | manager-gui | `{"id", "op"}` — `start`, `stop` or `restart` one component (a restart is a stop, when the component is running, followed by a start); not every change takes effect until the affected component is restarted, so the operation makes the restart explicit; 400 `NOT_A_LIFECYCLE` when the component does not implement `Lifecycle`, 400 `INVALID_OP` for an unknown operation, 400 `START_FAILED` / `STOP_FAILED` when the component does not start (stop), 403 `SELF_COMPONENT` for a `start` or `stop` of a component that would interrupt access to this web application itself (the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context) — a `restart` of such a component remains allowed: the client's connection may be interrupted during the operation, but the component is running again at the end and the client reconnects (re-logging in when the admin session was reset by the restart) | + | GET | `/api/config/store/preview` | manager-gui | `{"xml", "files", "restartsManager"}` — the resulting `server.xml`, the external context files that would be rewritten, and whether the save restarts the manager; read-only, the external files are captured in memory and nothing is written | | POST | `/api/config/store` | manager-gui | persist the live state (`{ok, file, backup}`): a timestamped backup of the previous `conf/server.xml` is kept and each context is written back to its current location — inline in `server.xml` if defined inline, its own file otherwise (mirroring regular StoreConfig) | Node ids are path-based: `server/service/Catalina/engine/Catalina/host/localhost/context/+manager2`. @@ -355,6 +356,21 @@ server-side timestamp, so restarts and clock skew are handled. suppressed client-side ("No changes to apply."). The attributes `name`, `path` and `defaultHost` are treated as risky and require a type-to-confirm. +- *Lifecycle*: components that implement `Lifecycle` (the node detail + reports this as `lifecycle`) show **Start** / **Stop** / **Restart** + buttons in the detail card header, since not every change takes + effect until the affected component is restarted. Start is disabled + while the component is running; Stop while it is stopped. Start and + Stop are additionally disabled (Restart stays enabled) for the + components that would interrupt access to this page (the node detail + reports this as `affectsSelf`: the server, the service/engine/host/ + context that route it, the connector that serves it and the wrappers + of its context). Restarting such a component interrupts the + connection mid-operation; the page then polls the server back, + reconnects, and re-logs in when the restart reset the admin session + (server, service, engine, host or the context itself). Stop asks for + a plain confirmation; Restart for typing the component's display + name. - *+ Add* (shown for nodes that can have children) opens a modal with the child types valid for the selected node (server → service; service → connector, executor; engine → host, realm, valve, cluster; diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 29cdae3c80da..4e18e8e4b9c8 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -139,12 +139,22 @@ * engines, hosts, contexts, wrappers, valves, connectors, executors, listeners, host aliases, realms, TLS host * configurations, the context sub components manager, session id generator, resources, loader and cookie processor, and * the JNDI naming resources of the server and of the contexts), allows reading and updating the descriptor defined - * attributes of any component, adding and removing child components, and persisting the live state to - * {@code conf/server.xml} through the storeconfig mechanism. + * attributes of any component, adding and removing child components, starting, stopping and restarting any component + * that implements {@code Lifecycle}, and persisting the live state to {@code conf/server.xml} through the storeconfig + * mechanism. *

* Changes are applied to the running server immediately. Persisting (store) rewrites {@code conf/server.xml} from the * live state and keeps a timestamped backup of the previous file. *

+ * Lifecycle operations. Not every change takes effect until the affected component is restarted (which + * attributes apply live is not documented), so the API offers explicit lifecycle operations: {@code start}, {@code + * stop} and {@code restart} (a stop followed by a start) for every component that implements {@code Lifecycle}. A + * {@code start} or {@code stop} of a component that would interrupt access to this web application (the server itself, + * the service, engine, host or context that route it, the connector that serves it and the wrappers of the context it + * runs in) is refused (403 {@code SELF_COMPONENT}): the request would lose its way back. A {@code restart} of such a + * component is still allowed: the client's connection may be interrupted during the operation, but the component is + * running again at the end and the client reconnects (re-logging in when the admin session was reset by the restart). + *

* Contexts keep their storage location when the configuration is persisted, mirroring regular StoreConfig * behavior: a context that is backed by its own configuration file (its {@code META-INF/context.xml} or * {@code conf/Catalina/.../context.xml}) is written back to that file, and a context defined inline in @@ -361,6 +371,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) addChild(response, body); } else if ("/api/config/store".equals(path)) { store(response); + } else if ("/api/config/lifecycle".equals(path)) { + lifecycle(response, body); } else { Api.notFound(response); } @@ -1041,6 +1053,20 @@ private Map node(NodeRef ref) throws ConfigException { // is just a name, not a component that holds listeners). out.put("acceptsListener", !"alias".equals(ref.type) && !CLUSTER_SUB_TYPES.contains(ref.type) && component instanceof Lifecycle); + // Whether the component has a lifecycle (and therefore supports + // the start / stop / restart operations). + if (component instanceof Lifecycle) { + out.put("lifecycle", Boolean.TRUE); + // Whether a start or stop of the component would interrupt + // access to this web application (only a restart is allowed + // for it; the client reconnects at the end of the operation). + // Not derivable on the client for the service, the engine + // and the connector (which of them route the requests of + // this web application is a server-side decision). + if (affectsSelf(ref)) { + out.put("affectsSelf", Boolean.TRUE); + } + } if ("connector".equals(ref.type) && component instanceof Connector connector) { out.put("sslEnabled", isSslEnabled(connector)); @@ -4730,6 +4756,126 @@ private static ConfigException badParent(String type) { } + // -------------------------------------------------------- Lifecycle ops + + + /** + * Start, stop or restart one component of the server tree. + *

+ * A {@code start} or {@code stop} of a component that would interrupt access to this web application itself (see + * {@link #affectsSelf(NodeRef)}) is refused (403 {@code SELF_COMPONENT}): the request would lose its way back. A + * {@code restart} (a stop, when the component is running, followed by a start) remains possible for those + * components: the client's connection may be interrupted during the operation, but the component is running again + * at the end and the client reconnects (re-logging in when the admin session was reset by the restart). + */ + private void lifecycle(HttpServletResponse response, Map body) throws Exception { + + String id = string(body.get("id")); + String op = string(body.get("op")); + if (id == null || op == null || op.isEmpty()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", + sm.getString("manager2.configInvalidId")); + } + boolean start = "start".equals(op); + boolean stop = "stop".equals(op); + boolean restart = "restart".equals(op); + if (!start && !stop && !restart) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_OP", + sm.getString("manager2.configInvalidOp", op)); + } + + NodeRef ref = resolve(id); + if (!(ref.component instanceof Lifecycle lifecycle)) { + String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "NOT_A_LIFECYCLE", + sm.getString("manager2.configNotALifecycle", label)); + } + if (!restart && affectsSelf(ref)) { + throw self(); + } + String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); + + if (stop) { + if (!lifecycle.getState().isAvailable()) { + Api.ok(response, sm.getString("manager2.configAlreadyStopped", label)); + return; + } + stopChecked(lifecycle, label); + log(sm.getString("manager2.configAuditLifecycle", "stopped", label)); + Api.ok(response, sm.getString("manager2.configStopped", label)); + return; + } + + if (restart && lifecycle.getState().isAvailable()) { + stopChecked(lifecycle, label); + } + if (start && lifecycle.getState().isAvailable()) { + Api.ok(response, sm.getString("manager2.configAlreadyRunning", label)); + return; + } + startChecked(lifecycle, label); + log(sm.getString("manager2.configAuditLifecycle", restart ? "restarted" : "started", label)); + Api.ok(response, sm.getString(restart ? "manager2.configRestarted" : "manager2.configStarted", label)); + } + + + /** + * Stop the component, raising a controlled error when it cannot be stopped or does not stop. + */ + private void stopChecked(Lifecycle lifecycle, String label) throws ConfigException { + try { + lifecycle.stop(); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", + sm.getString("manager2.configStopFailed", label, rootMessage(e))); + } + if (lifecycle.getState().isAvailable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", + sm.getString("manager2.configStopFailed", label, "the component did not stop")); + } + } + + + /** + * Start the component, raising a controlled error when it cannot be started or does not start. + */ + private void startChecked(Lifecycle lifecycle, String label) throws ConfigException { + try { + lifecycle.start(); + } catch (Exception e) { + log(sm.getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", label, rootMessage(e))); + } + if (!lifecycle.getState().isAvailable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + sm.getString("manager2.configStartFailed", label, "the component did not start")); + } + } + + + /** + * Whether stopping or starting this component would interrupt access to this web application: the component is the + * server itself, or the service, engine, host, context, connector or wrapper that route or host the requests of + * this web application. A {@code restart} of such a component remains possible (the client reconnects at the end + * of the operation). + */ + private boolean affectsSelf(NodeRef ref) { + Object component = ref.component; + return switch (ref.type) { + case "server" -> true; + case "service" -> component instanceof StandardService service && containsSelf(service); + case "engine" -> component instanceof Engine engine && engine.findChild(selfHost.getName()) instanceof Host; + case "host" -> component == selfHost; + case "context" -> component == selfContext; + case "connector" -> component instanceof Connector connector && isSelfConnector(connector); + case "wrapper" -> component instanceof Wrapper wrapper && wrapper.getParent() == selfContext; + default -> false; + }; + } + + // ------------------------------------------------------------- Store diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties index 3dc038168fd7..a139dc349354 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -72,7 +72,7 @@ manager2.configConfirmRequired=This operation requires confirmation. Type [{0}] manager2.configRequiredComponent=The {0} of a context is required and cannot be removed. Replace it with a different implementation instead. manager2.configRequiredNamingResources=The JNDI naming resources cannot be removed. manager2.configContextMustBeStopped=The resources of a running context cannot be replaced. Stop the context [{0}] first. -manager2.configSelfComponent=This component hosts the manager web application itself and cannot be renamed or removed. +manager2.configSelfComponent=This component hosts the manager web application itself and cannot be renamed, removed, started or stopped. manager2.configBasicValve=The basic valve of a container cannot be removed. manager2.configLastRealm=The realm of this container cannot be removed: the container would be left without a realm. Add a replacement realm first. manager2.configLastService=The last service of the server cannot be removed. @@ -102,7 +102,16 @@ manager2.configSslLastCertificate=The last certificate of the running, TLS enabl manager2.configSslReloadFailed=The attribute [{0}] of [{1}] was reverted: the TLS configuration of the connector does not accept it: {2} manager2.configStoreFailed=The configuration could not be written to server.xml. manager2.configStored=The configuration has been written to {0}. Backup: {1} +manager2.configInvalidOp=The lifecycle operation [{0}] is not valid. +manager2.configNotALifecycle=The component [{0}] has no lifecycle: it cannot be started, stopped or restarted. +manager2.configStopFailed=The component [{0}] could not be stopped: {1} +manager2.configStopped=The component [{0}] has been stopped. +manager2.configStarted=The component [{0}] has been started. +manager2.configRestarted=The component [{0}] has been restarted. +manager2.configAlreadyRunning=The component [{0}] is already running. +manager2.configAlreadyStopped=The component [{0}] is already stopped. manager2.configAuditAttribute=Config: set attribute [{0}] of [{1}] to [{2}] manager2.configAuditAdd=Config: added {0} [{1}] manager2.configAuditRemove=Config: removed {0} [{1}] manager2.configAuditStore=Config: stored configuration to conf/server.xml (backup: {0}) +manager2.configAuditLifecycle=Config: {0} component [{1}] diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java index ccc539b72c46..1d03c1591717 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -52,7 +52,8 @@ * Integration tests for the manager2 configuration API ({@code /api/config/*}). The tests deploy the * {@code manager2.war} built by this module (via the {@code deploy} target) into a throw-away Tomcat instance and drive * it over HTTP with {@link SimpleHttpClient}, exercising the component tree, attribute updates, structural add/remove - * of child components, and persistence to {@code server.xml} through storeconfig. + * of child components, lifecycle operations (start / stop / restart), and persistence to {@code server.xml} through + * storeconfig. */ public class TestManager2Config extends TomcatBaseTest { @@ -409,6 +410,162 @@ public void testAddRemoveChildTypes() throws Exception { } + @Test + public void testLifecycle() throws Exception { + setup(true); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + String serviceId = null; + String hostId = null; + String contextId = null; + + try { + // A throw-away service that never hosts this web application. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"server\",\"type\":\"service\",\"name\":\"LifecycleSvc\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + serviceId = "server/service/LifecycleSvc"; + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serviceId + "/engine/LifecycleSvc\"," + + "\"type\":\"host\",\"name\":\"lifecycle-host\"}", 200); + hostId = serviceId + "/engine/LifecycleSvc/host/lifecycle-host"; + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + hostId + "\",\"type\":\"context\",\"path\":\"/lifecycle\"}", 200); + contextId = hostId + "/context/+lifecycle"; + + // The node detail flags ------------------------------------- + // + // The server is a lifecycle, and a start or stop of it always + // affects this web application. + Map serverNode = fetchNode(client, "server"); + Assert.assertEquals(Boolean.TRUE, serverNode.get("lifecycle")); + Assert.assertEquals(Boolean.TRUE, serverNode.get("affectsSelf")); + + // The components that route this web application are reported + // as affecting it as well. Their ids are derived from the id + // of the self context (server/service/{s}/engine/{e}/host/{h}/ + // context/{p}). + String selfCtxId = selfContextId(fetchTree(client)); + int ctxIx = selfCtxId.lastIndexOf("/context/"); + String selfHostId = selfCtxId.substring(0, ctxIx); + int hostIx = selfHostId.lastIndexOf("/host/"); + String selfEngineId = selfHostId.substring(0, hostIx); + int engIx = selfEngineId.lastIndexOf("/engine/"); + String selfServiceId = selfEngineId.substring(0, engIx); + String selfConnectorId = selfServiceId + "/connector/0"; + for (String id : new String[] { selfServiceId, selfEngineId, selfHostId, selfCtxId, + selfConnectorId }) { + Map node = fetchNode(client, id); + Assert.assertEquals(Boolean.TRUE, node.get("lifecycle")); + Assert.assertEquals("Expected " + id + " to affect this web application", + Boolean.TRUE, node.get("affectsSelf")); + } + // A wrapper of the context that runs this web application + // affects it as well. + Map selfWrapper = firstChildOfType(fetchNode(client, selfCtxId), "wrapper"); + Assert.assertNotNull(selfWrapper); + Map wrapperNode = fetchNode(client, (String) selfWrapper.get("id")); + Assert.assertEquals(Boolean.TRUE, wrapperNode.get("lifecycle")); + Assert.assertEquals(Boolean.TRUE, wrapperNode.get("affectsSelf")); + + // The throw-away components are lifecycles as well, but they + // do not affect this web application. + for (String id : new String[] { serviceId, hostId, contextId }) { + Map node = fetchNode(client, id); + Assert.assertEquals(Boolean.TRUE, node.get("lifecycle")); + Assert.assertNull("Expected " + id + " not to affect this web application", + node.get("affectsSelf")); + } + // A valve is a lifecycle as well (ValveBase extends + // LifecycleBase). + Map valveNode = fetchNode(client, selfCtxId + "/valve/0"); + Assert.assertEquals(Boolean.TRUE, valveNode.get("lifecycle")); + Assert.assertNull(valveNode.get("affectsSelf")); + + // A component without a lifecycle (a JNDI entry) does not + // report the flag at all. + String envId = selfCtxId + "/namingResources/0/environment/lcenv"; + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + selfCtxId + "/namingResources/0\",\"type\":\"environment\"," + + "\"name\":\"lcenv\",\"jndiType\":\"java.lang.String\",\"value\":\"x\"}", + 200); + Map envNode = fetchNode(client, envId); + Assert.assertNull(envNode.get("lifecycle")); + + // Guards: a start or stop of a component that affects this + // web application is refused (a restart is not - but it is + // not exercised here, as it would restart the very server + // that serves this test). + for (String id : new String[] { "server", selfServiceId, selfEngineId, selfHostId, + selfCtxId, selfConnectorId, (String) selfWrapper.get("id") }) { + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + id + "\",\"op\":\"stop\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + id + "\",\"op\":\"start\"}", 403); + Assert.assertTrue(client.getResponseBody().contains("SELF_COMPONENT")); + } + + // Stop / start / restart of a component that does not affect + // this web application. + // + // Stop. + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"stop\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + Map ctx = fetchNode(client, contextId); + Assert.assertEquals("STOPPED", ctx.get("state")); + // A second stop is a (reported) no-op. + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"stop\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("already stopped")); + // Start. + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"start\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + ctx = fetchNode(client, contextId); + Assert.assertEquals("STARTED", ctx.get("state")); + // A second start is a (reported) no-op. + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"start\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("already running")); + // Restart (stop followed by start). + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"restart\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + ctx = fetchNode(client, contextId); + Assert.assertEquals("STARTED", ctx.get("state")); + + // Guards: an unknown operation and a component without a + // lifecycle are rejected. + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + contextId + "\",\"op\":\"bogus\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_OP")); + request(client, "POST", MANAGER2 + "/api/config/lifecycle", token, + "{\"id\":\"" + envId + "\",\"op\":\"stop\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("NOT_A_LIFECYCLE")); + + // Remove the throw-away components (leaves first). + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + envId + "\",\"confirm\":\"lcenv\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + contextId + "\",\"confirm\":\"/lifecycle\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + hostId + "\",\"confirm\":\"lifecycle-host\"}", 200); + request(client, "DELETE", MANAGER2 + "/api/config/child", token, + "{\"id\":\"" + serviceId + "\",\"confirm\":\"LifecycleSvc\"}", 200); + } finally { + cleanup(client, token, serviceId, hostId, contextId, null, null, null, null, null); + } + + client.disconnect(); + } + + @Test public void testAddRemoveListener() throws Exception { setup(); diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index a7e50bc114cc..c4df46f0f817 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -291,6 +291,7 @@ table.data .num { text-align: right; font-variant-numeric: tabular-nums; } table.data .muted { color: var(--text-faint); } .row-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.row-actions .row-actions-sep { align-self: stretch; width: 1px; margin: 2px 3px; background: var(--border); } /* ============================ Log pages ================================= */ diff --git a/modules/manager2/webapp/js/pages/configuration.js b/modules/manager2/webapp/js/pages/configuration.js index 3934e87aca32..b69712e632f0 100644 --- a/modules/manager2/webapp/js/pages/configuration.js +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -265,7 +265,8 @@ export async function configuration(container) { disabled: d.self, title: d.self ? 'Cannot remove the component the manager is installed in' : 'Remove', onclick: () => removeNode(d), - }, 'Remove') : null)); + }, 'Remove') : null, + ...lifecycleButtons(d))); detailCard.append(head); if (d.className) { @@ -891,6 +892,133 @@ export async function configuration(container) { el('p', {}, 'Select a component in the tree to inspect and edit it.'))); } + // ============================ Lifecycle ============================= + + // The states that count as "running" for a component. A context + // reports STARTED while it is accepting requests; AVAILABLE is + // accepted as well (some components report it instead). + function isRunning(d) { + return d.state === 'STARTED' || d.state === 'AVAILABLE'; + } + + // The Start / Stop / Restart buttons of the detail card, for the + // components that implement Lifecycle (the node detail reports this + // as `lifecycle`). Not every change takes effect until the affected + // component is restarted, so the buttons make the restart explicit. + // Start and Stop are disabled for the components that affect access + // to this page (`affectsSelf`): stopping them would destroy the admin + // session mid-request. Restart stays enabled for them: the client's + // connection may be interrupted during the operation, but the + // component is running again at the end and the client reconnects. + function lifecycleButtons(d) { + if (!d.lifecycle) return []; + const running = isRunning(d); + const selfImpact = d.affectsSelf; + const selfImpactTitle = 'This component serves this page: starting or stopping it would interrupt access to the manager. Use Restart instead.'; + return [ + el('span', { class: 'row-actions-sep', role: 'presentation' }), + el('button', { + type: 'button', class: 'btn btn-sm', + disabled: running || selfImpact, + title: selfImpact ? selfImpactTitle : 'Start', + onclick: () => lifecycleOp(d, 'start'), + }, 'Start'), + el('button', { + type: 'button', class: 'btn btn-sm', + disabled: !running || selfImpact, + title: selfImpact ? selfImpactTitle : 'Stop', + onclick: () => lifecycleOp(d, 'stop'), + }, 'Stop'), + el('button', { + type: 'button', class: 'btn btn-sm', + title: 'Stop the component and start it again', + onclick: () => lifecycleOp(d, 'restart'), + }, 'Restart'), + ]; + } + + async function lifecycleOp(d, op) { + const label = d.name || d.type; + let ok; + if (op === 'start') { + // Starting a stopped component is safe: no confirmation. + ok = true; + } else if (op === 'stop') { + ok = await confirm({ + title: 'Stop ' + d.type, + message: 'Stop ' + label + '? Any in-memory state it holds (e.g. the sessions of the contexts below it) is lost.', + confirmLabel: 'Stop', + danger: false, + }); + } else { + ok = await confirm({ + title: 'Restart ' + d.type, + message: 'Restart ' + label + '?' + (d.affectsSelf + ? ' This component serves this page: the connection is interrupted during the operation and the page reconnects when it is done. When the restarted component holds the admin sessions (the server, a service, an engine, a host or this context) you will need to sign in again.' + : ''), + confirmLabel: 'Restart', + danger: true, + requireText: label, + }); + } + if (!ok) return; + let res; + try { + res = await api('POST', '/api/config/lifecycle', { id: d.id, op }); + } catch (err) { + // A network-level failure (fetch rejects with a TypeError) means + // the connection was interrupted mid-operation: what is expected + // when the component that serves this page itself is restarted. + // The operation may well have completed server-side; try to + // reconnect. + if (err && err.name === 'TypeError') { + await reconnectAfterLifecycle(); + } else { + toast(err.message, 'error'); + } + return; + } + toast(res.message, 'ok'); + await loadTree(); + if (selectedId) { + selectNode(selectedId); + } else { + renderEmpty(); + } + } + + // The connection was interrupted during a lifecycle operation (the + // component that serves this page - the server, a service, an engine, + // the host, the connector or this context - was restarted and its + // start phase has not necessarily finished yet). Wait for the server + // to come back and reconnect: a read-only API call re-establishes the + // CSRF token; when the admin session was reset by the restart, api() + // navigates to the login page, and a successful login returns to this + // page. + async function reconnectAfterLifecycle() { + toast('The connection was interrupted during the operation - this is expected when the component that serves this page is restarted. Reconnecting...', 'info', 8000); + for (let attempt = 0; attempt < 10; attempt++) { + await sleep(1000); + try { + await api('GET', '/api/csrf'); + toast('Reconnected. Reloading the components.', 'ok'); + await loadTree(); + if (selectedId) { + selectNode(selectedId); + } else { + renderEmpty(); + } + return; + } catch (err) { + // api() has already navigated to the login page. + if (err && err.message === 'unauthenticated') return; + } + } + toast('Could not reconnect after the operation. The component may still be stopped - check the server status and try again.', 'error', 10000); + } + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + // ============================ Save to server.xml =================== async function saveToServerXml() { From c3b807d7228c275785d693d0b1bd1b89492395da Mon Sep 17 00:00:00 2001 From: remm Date: Tue, 15 Sep 2026 19:55:31 +0200 Subject: [PATCH 04/17] Add UpgradeProtocol configuration --- modules/manager2/manager2-design.md | 42 +++- .../tomcat/manager2/ConfigApiServlet.java | 216 ++++++++++++++++-- .../tomcat/manager2/LocalStrings.properties | 2 + .../tomcat/manager2/TestManager2Config.java | 154 +++++++++++++ .../manager2/webapp/js/pages/configuration.js | 11 +- 5 files changed, 399 insertions(+), 26 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 499d696f08c8..8caf17794db0 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -85,7 +85,7 @@ in manager2, mapped as follows: | Feature | manager2 page / API | |---|---| - | Browse and edit the whole live server configuration (services, engines, hosts, contexts, wrappers, valves, connectors, executors, aliases, lifecycle listeners, realms with sub realms, the context sub components — manager with its session id generator, resources, loader, cookie processor — the cluster and its full channel (membership, sender + transport, receiver, interceptors, deployer, manager template, cluster valves and listeners) on any container, the JNDI naming resources of the server and of each context, and TLS: SSL host configurations with their certificates): property editing, structural add/remove, start/stop/restart of any component with a lifecycle, persistence to `server.xml` | Configuration page / `GET /api/config/*` (the legacy manager required hand-editing `server.xml` and a restart for anything beyond the host manager's "persist" button; TLS, realms and JNDI entries apply live, without a restart) | + | Browse and edit the whole live server configuration (services, engines, hosts, contexts, wrappers, valves, connectors, executors, aliases, lifecycle listeners, realms with sub realms, the context sub components — manager with its session id generator, resources, loader, cookie processor — the cluster and its full channel (membership, sender + transport, receiver, interceptors, deployer, manager template, cluster valves and listeners) on any container, the JNDI naming resources of the server and of each context, TLS: SSL host configurations with their certificates, and the upgrade protocols of a connector): property editing, structural add/remove, start/stop/restart of any component with a lifecycle, persistence to `server.xml` | Configuration page / `GET /api/config/*` (the legacy manager required hand-editing `server.xml` and a restart for anything beyond the host manager's "persist" button; TLS, realms and JNDI entries apply live, without a restart) | ## 3. Architecture @@ -220,8 +220,8 @@ machine-readable `error` code and non-2xx status. | GET | `/api/config/tree` | manager-gui | the live component tree below `Server`: `{"tree": {id, type, className, name, state?, self?, children[]}}`; `self: true` on the context hosting this webapp; a context carries its `manager` (with the manager's `sessionIdGenerator`), `resources`, `loader` and `cookieProcessor` as children (a running context always has all of them); the `Server` and each context carry a single `namingResources` node (the `NamingResourcesImpl`) whose children are the JNDI entries, keyed by JNDI name — `resource`, `resourceLink` (context only), `resourceEnvRef`, `environment`, `ejb`, `localEjb`, `serviceRef`; an engine, host or context that owns a cluster carries a single `cluster` child (an inherited parent cluster is not a child) whose children are the `channel` (holding `membership`, `sender` — with a `transport` child for a replication transmitter —, `receiver` and the repeatable `interceptor` nodes), the repeatable `clusterValve`, the `clusterManager` (with its `sessionIdGenerator`), the repeatable `clusterListener` and the repeatable `listener` | | GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `lifecycle` (whether the component implements `Lifecycle` and therefore supports the start/stop/restart operations), `affectsSelf` (whether a start or stop of the component would interrupt access to this web application itself — the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context; only a restart is allowed for it), `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description`, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | | POST | `/api/config/attribute` | manager-gui | `{"id", "name", "value", "confirm"?}` — set one writable property on the live component (the change takes effect immediately); `name`/`path`/`defaultHost` additionally require `confirm` to equal the current value; a TLS attribute of a running, TLS enabled connector is re-validated by reloading the affected host configuration and the previous value is restored (400 `UPDATE_FAILED`) when the new value does not validate. For a JNDI entry, editing an attribute (or a `param` — an empty value removes it) is applied by removing and re-adding the entry (the `NamingContextListener` reacts to the property-change events to rebind the live JNDI environment); the previous state is restored (400 `UPDATE_FAILED`) when the re-add fails (e.g. the new JNDI name is already in use) | - | POST | `/api/config/child` | manager-gui | `{"parent", "type", ...}` — add a `service` (created together with an engine of the same name), `host`, `context` (docBase auto-created), `wrapper`, `valve`, `connector`, `executor`, `alias`, `realm` (any container, or a `CombinedRealm` for a sub realm; instantiated from `className`), a context sub component — `manager`, `resources`, `loader` or `cookieProcessor` (parent: a context) or `sessionIdGenerator` (parent: a manager; all instantiated from `className` and **replacing** the current instance, since a context/manager holds exactly one of each) — or `listener` (any parent whose component implements `Lifecycle`; instantiated from `className` and registered, not started); a `cluster` (parent: an engine, host or context) is instantiated from `className` (default `SimpleTcpCluster`) and attached via `setCluster`, which starts the channel and applies the cluster defaults (the addition is rolled back + 400 `START_FAILED` when it cannot start), and its sub components are added by `className` with a sensible default — `channel` (default `GroupChannel`, replacing the current one), `membership` (default `McastService`, parent: the channel), `sender` (default `ReplicationTransmitter`, parent: the channel), `receiver` (default `NioReceiver`, parent: the channel), `interceptor` (parent: the channel, repeatable), `clusterValve` (a `Valve` that is a `ClusterValve`, parent: the cluster, repeatable; 400 `INVALID_CLASS` otherwise), `deployer` (default `FarmWarDeployer`, parent: the cluster), `clusterManager` (default `DeltaManager`, parent: the cluster), `transport` (default `PooledParallelSender`, parent: the sender) and `clusterListener` (parent: the cluster, repeatable); the single-valued slots replace the current instance; structural components are started immediately and the addition rolled back when the start fails. Replacing a context's `manager` or `loader` on a running context stops the old instance and starts the new one (rolled back + 400 `START_FAILED` when it does not start); replacing the `resources` of a running context is refused (400 `CONTEXT_RUNNING` — stop the context first); replacing the `manager` or `loader` of this webapp's own context is refused (403 `SELF_COMPONENT` — it would destroy the admin session / the running classes). A `sslHostConfig` (parent: a connector) optionally carries an initial `certificate` object; on a running connector the TLS configuration is validated and applied without a restart (400 `ADD_FAILED` + rollback otherwise); the first certificate is required for a running connector (400 `INVALID_VALUE`). A further `certificate` is added with the crypto type in the `type` field (`RSA`, `DSA`, ...). A JNDI entry (`parent`: a `namingResources` node) — `resource`, `resourceLink` (refused at the server level, 400 `BAD_PARENT`), `resourceEnvRef`, `environment`, `ejb`, `localEjb` or `serviceRef` — requires `name` and `jndiType`; a `resourceLink` additionally requires `global`; a `factory` (a `resource` parameter / `resourceLink` attribute) must be loadable (400 `INVALID_CLASS`); a `params` object carries the entry's generic string parameters (the `ResourceBase` property map) for any entry type — validated against the closed option set of a first-party factory for a `resource`, free-form otherwise; a JNDI name already in use is 409 `DUPLICATE`, a missing `jndiType` 400 `MISSING_FIELD`; the entry is registered and bound in the live JNDI environment at once | - | DELETE | `/api/config/child` | manager-gui | `{"id", "confirm"?}` — remove a component (containers are stopped recursively); `confirm` must equal the component's display name for `host`/`context`/`service`/`engine`/`connector`/`executor`/`wrapper`/`valve`/`sslHostConfig`/`realm`/`cluster` and for the JNDI entries (`resource`/`resourceLink`/`resourceEnvRef`/`environment`/`ejb`/`localEjb`/`serviceRef`, whose JNDI name is unbound from the live JNDI environment); 400 `LAST_SERVICE`, 403 `SELF_COMPONENT`, 400 `BASIC_COMPONENT`, 400 `NOT_EMPTY`, 400 `LAST_REALM` (the container would be left without a realm), 400 `REQUIRED_COMPONENT` (the context's `manager`/`resources`/`loader`/`cookieProcessor`, a manager's `sessionIdGenerator` and the `namingResources` node itself are required and cannot be removed), 400 `SSL_DEFAULT` and 400 `SSL_LAST_CERTIFICATE` guards apply; removing the last SSL host configuration of a running, TLS enabled connector switches it back to plain HTTP; a `cluster` (which stops the cluster and its channel) and its single-valued children (`channel`, `membership`, `sender`, `receiver`, `deployer`, `clusterManager`, `transport`, `clusterListener` and the static `member`) can be detached from their parent, but a `clusterValve` or `interceptor` has no removal API on a running cluster and is refused (400 `REMOVE_NOT_SUPPORTED`) | + | POST | `/api/config/child` | manager-gui | `{"parent", "type", ...}` — add a `service` (created together with an engine of the same name), `host`, `context` (docBase auto-created), `wrapper`, `valve`, `connector`, `executor`, `alias`, `realm` (any container, or a `CombinedRealm` for a sub realm; instantiated from `className`), a context sub component — `manager`, `resources`, `loader` or `cookieProcessor` (parent: a context) or `sessionIdGenerator` (parent: a manager; all instantiated from `className` and **replacing** the current instance, since a context/manager holds exactly one of each) — or `listener` (any parent whose component implements `Lifecycle`; instantiated from `className` and registered, not started); a `cluster` (parent: an engine, host or context) is instantiated from `className` (default `SimpleTcpCluster`) and attached via `setCluster`, which starts the channel and applies the cluster defaults (the addition is rolled back + 400 `START_FAILED` when it cannot start), and its sub components are added by `className` with a sensible default — `channel` (default `GroupChannel`, replacing the current one), `membership` (default `McastService`, parent: the channel), `sender` (default `ReplicationTransmitter`, parent: the channel), `receiver` (default `NioReceiver`, parent: the channel), `interceptor` (parent: the channel, repeatable), `clusterValve` (a `Valve` that is a `ClusterValve`, parent: the cluster, repeatable; 400 `INVALID_CLASS` otherwise), `deployer` (default `FarmWarDeployer`, parent: the cluster), `clusterManager` (default `DeltaManager`, parent: the cluster), `transport` (default `PooledParallelSender`, parent: the sender) and `clusterListener` (parent: the cluster, repeatable); the single-valued slots replace the current instance; structural components are started immediately and the addition rolled back when the start fails. Replacing a context's `manager` or `loader` on a running context stops the old instance and starts the new one (rolled back + 400 `START_FAILED` when it does not start); replacing the `resources` of a running context is refused (400 `CONTEXT_RUNNING` — stop the context first); replacing the `manager` or `loader` of this webapp's own context is refused (403 `SELF_COMPONENT` — it would destroy the admin session / the running classes). A `sslHostConfig` (parent: a connector) optionally carries an initial `certificate` object; on a running connector the TLS configuration is validated and applied without a restart (400 `ADD_FAILED` + rollback otherwise); the first certificate is required for a running connector (400 `INVALID_VALUE`). A further `certificate` is added with the crypto type in the `type` field (`RSA`, `DSA`, ...). An `upgradeProtocol` (parent: a connector) is instantiated from `className` (default `org.apache.coyote.http2.Http2Protocol`, the only `UpgradeProtocol` shipped with Tomcat) and registered through `AbstractHttp11Protocol.addUpgradeProtocol`; a connector whose protocol handler is not the HTTP/1.1 variant does not accept one (400 `BAD_PARENT`), a class that is not an `UpgradeProtocol` is 400 `INVALID_CLASS`, a second protocol with the same name (e.g. a second `h2`) is 409 `DUPLICATE`; an upgrade protocol is only referenced when the connector is initialised, so no live activation is attempted — the protocol becomes active the next time the connector is restarted. A JNDI entry (`parent`: a `namingResources` node) — `resource`, `resourceLink` (refused at the server level, 400 `BAD_PARENT`), `resourceEnvRef`, `environment`, `ejb`, `localEjb` or `serviceRef` — requires `name` and `jndiType`; a `resourceLink` additionally requires `global`; a `factory` (a `resource` parameter / `resourceLink` attribute) must be loadable (400 `INVALID_CLASS`); a `params` object carries the entry's generic string parameters (the `ResourceBase` property map) for any entry type — validated against the closed option set of a first-party factory for a `resource`, free-form otherwise; a JNDI name already in use is 409 `DUPLICATE`, a missing `jndiType` 400 `MISSING_FIELD`; the entry is registered and bound in the live JNDI environment at once | + | DELETE | `/api/config/child` | manager-gui | `{"id", "confirm"?}` — remove a component (containers are stopped recursively); `confirm` must equal the component's display name for `host`/`context`/`service`/`engine`/`connector`/`executor`/`wrapper`/`valve`/`sslHostConfig`/`upgradeProtocol`/`realm`/`cluster` and for the JNDI entries (`resource`/`resourceLink`/`resourceEnvRef`/`environment`/`ejb`/`localEjb`/`serviceRef`, whose JNDI name is unbound from the live JNDI environment); 400 `LAST_SERVICE`, 403 `SELF_COMPONENT`, 400 `BASIC_COMPONENT`, 400 `NOT_EMPTY`, 400 `LAST_REALM` (the container would be left without a realm), 400 `REQUIRED_COMPONENT` (the context's `manager`/`resources`/`loader`/`cookieProcessor`, a manager's `sessionIdGenerator` and the `namingResources` node itself are required and cannot be removed), 400 `SSL_DEFAULT` and 400 `SSL_LAST_CERTIFICATE` guards apply; removing the last SSL host configuration of a running, TLS enabled connector switches it back to plain HTTP; a `cluster` (which stops the cluster and its channel) and its single-valued children (`channel`, `membership`, `sender`, `receiver`, `deployer`, `clusterManager`, `transport`, `clusterListener` and the static `member`) can be detached from their parent, but a `clusterValve` or `interceptor` has no removal API on a running cluster and is refused (400 `REMOVE_NOT_SUPPORTED`) | | POST | `/api/config/lifecycle` | manager-gui | `{"id", "op"}` — `start`, `stop` or `restart` one component (a restart is a stop, when the component is running, followed by a start); not every change takes effect until the affected component is restarted, so the operation makes the restart explicit; 400 `NOT_A_LIFECYCLE` when the component does not implement `Lifecycle`, 400 `INVALID_OP` for an unknown operation, 400 `START_FAILED` / `STOP_FAILED` when the component does not start (stop), 403 `SELF_COMPONENT` for a `start` or `stop` of a component that would interrupt access to this web application itself (the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context) — a `restart` of such a component remains allowed: the client's connection may be interrupted during the operation, but the component is running again at the end and the client reconnects (re-logging in when the admin session was reset by the restart) | | GET | `/api/config/store/preview` | manager-gui | `{"xml", "files", "restartsManager"}` — the resulting `server.xml`, the external context files that would be rewritten, and whether the save restarts the manager; read-only, the external files are captured in memory and nothing is written | | POST | `/api/config/store` | manager-gui | persist the live state (`{ok, file, backup}`): a timestamped backup of the previous `conf/server.xml` is kept and each context is written back to its current location — inline in `server.xml` if defined inline, its own file otherwise (mirroring regular StoreConfig) | @@ -322,10 +322,11 @@ server-side timestamp, so restarts and clock skew are handled. hosts, contexts, wrappers, valves, connectors, executors, aliases, listeners, realms, the context sub components (manager, resources, loader, cookie processor; the manager's session id generator under - the manager), and the TLS branch of a connector: SSL host - configurations with their certificates). Each row: a type badge, the - name, a state badge for lifecycle components, and a "this app" badge - on the context hosting this webapp. Nodes expand/collapse; the + the manager), the TLS branch of a connector (SSL host + configurations with their certificates), and the upgrade protocols + of a connector). Each row: a type badge, the name, a state badge + for lifecycle components, and a "this app" badge on the context + hosting this webapp. Nodes expand/collapse; the `Server` node starts expanded. A container only shows the realm it owns; an inherited parent realm is not a child (the same comparison storeconfig uses to decide whether to write a `` @@ -376,8 +377,9 @@ server-side timestamp, so restarts and clock skew are handled. service → connector, executor; engine → host, realm, valve, cluster; host → context, alias, realm, valve, cluster; context → wrapper, realm, manager, resources, loader, cookieProcessor, valve, cluster; - manager → sessionIdGenerator; connector → sslHostConfig; sslHostConfig → - certificate; cluster → channel, deployer, clusterValve, clusterManager, + manager → sessionIdGenerator; connector → sslHostConfig, + upgradeProtocol; sslHostConfig → certificate; cluster → channel, + deployer, clusterValve, clusterManager, clusterListener; channel → membership, sender, receiver, interceptor; sender → transport; clusterManager → sessionIdGenerator; namingResources → resource, resourceLink (context only), @@ -414,6 +416,28 @@ server-side timestamp, so restarts and clock skew are handled. configuration switches the connector back to plain HTTP; because NIO channels are pooled, the pool is emptied when the SSL flag changes so no stale (in)secure channel is handed out. + - *Upgrade protocols*: adding an `upgradeProtocol` to a connector + registers it through `AbstractHttp11Protocol.addUpgradeProtocol`. + The default class is `org.apache.coyote.http2.Http2Protocol` (the + only `UpgradeProtocol` shipped with Tomcat); the form field carries + the class name, a connector whose protocol handler is not the + HTTP/1.1 variant does not accept one (400 `BAD_PARENT`), and a + second protocol with the same name is refused (409 `DUPLICATE`). + An upgrade protocol is only referenced when the connector is + initialised, so the addition does not change a running connector + and no live activation is attempted: the protocol becomes active + the next time the connector is (re)started. The HTTP/2 protocol + exposes its settings as editable properties (the timeouts, + `maxConcurrentStreams`, `maxConcurrentStreamExecution`, + `initialWindowSize`, the header/trailer limits, the overhead frame + tracking factors and thresholds, `useSendfile`, + `allowSchemeMismatch`, `initiatePingDisabled`, + `discardRequestsAndResponses` and `drainTimeout`; the list is + explicit, the class has no modeler descriptor — the same mechanism + as the TLS components); setting changes take effect when the + connector is (re)started, like the protocol itself. + `maxHeaderSize` and `maxTrailerSize` are shown read-only (they are + set on the HTTP/1.1 protocol handler). - *Realms*: a container holds at most one realm of its own (adding a second is 409 `DUPLICATE`). Removing a directly attached realm is only allowed when the container falls back to a parent realm diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 4e18e8e4b9c8..1cdc0de5d6f5 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -113,7 +113,9 @@ import org.apache.catalina.util.LifecycleBase; import org.apache.catalina.util.SessionIdGeneratorBase; import org.apache.coyote.AbstractProtocol; +import org.apache.coyote.UpgradeProtocol; import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.coyote.http2.Http2Protocol; import org.apache.tomcat.util.descriptor.web.ContextEjb; import org.apache.tomcat.util.descriptor.web.ContextEnvironment; import org.apache.tomcat.util.descriptor.web.ContextLocalEjb; @@ -137,11 +139,11 @@ /** * The Manager2 configuration API. Exposes the complete component tree of the Catalina {@code Server} (services, * engines, hosts, contexts, wrappers, valves, connectors, executors, listeners, host aliases, realms, TLS host - * configurations, the context sub components manager, session id generator, resources, loader and cookie processor, and - * the JNDI naming resources of the server and of the contexts), allows reading and updating the descriptor defined - * attributes of any component, adding and removing child components, starting, stopping and restarting any component - * that implements {@code Lifecycle}, and persisting the live state to {@code conf/server.xml} through the storeconfig - * mechanism. + * configurations, the upgrade protocols of a connector, the context sub components manager, session id generator, + * resources, loader and cookie processor, and the JNDI naming resources of the server and of the contexts), allows + * reading and updating the descriptor defined attributes of any component, adding and removing child components, + * starting, stopping and restarting any component that implements {@code Lifecycle}, and persisting the live state to + * {@code conf/server.xml} through the storeconfig mechanism. *

* Changes are applied to the running server immediately. Persisting (store) rewrites {@code conf/server.xml} from the * live state and keeps a timestamped backup of the previous file. @@ -198,6 +200,7 @@ * server/service/{s}/connector/{index} * server/service/{s}/connector/{i}/sslHostConfig/{hostName} * server/service/{s}/connector/{i}/sslHostConfig/{h}/certificate/{index} + * server/service/{s}/connector/{i}/upgradeProtocol/{index} * server/service/{s}/executor/{name} * server/service/{s}/valve/{index} * server/service/{s}/listener/{index} @@ -210,9 +213,10 @@ * Attributes. The property list of a node is derived from the modeler MBean descriptor of the component's class * (the same contract that defines the {@code Catalina:*} MBeans). The TLS components ({@code SSLHostConfig} and * {@code SSLHostConfigCertificate}), the context sub components ({@code WebappLoader}, {@code CookieProcessorBase} - * subclasses and {@code SessionIdGeneratorBase} subclasses) and the JNDI entry nodes have no (complete) modeler - * descriptor; their editable attribute list is defined explicitly by this servlet. Only attributes that map to a simple - * UI type (boolean, integral, string, string array) are editable; everything else is reported read-only. + * subclasses and {@code SessionIdGeneratorBase} subclasses), the HTTP/2 upgrade protocol + * ({@code org.apache.coyote.http2.Http2Protocol}) and the JNDI entry nodes have no (complete) modeler descriptor; their + * editable attribute list is defined explicitly by this servlet. Only attributes that map to a simple UI type (boolean, + * integral, string, string array) are editable; everything else is reported read-only. *

* Context sub components. A running context always has exactly one manager, one resource root, one loader and * one cookie processor (the defaults are created at context start). Adding one of these to a context therefore replaces @@ -227,6 +231,12 @@ * if the restart fails (for example because the keystore does not exist or the password is wrong). The connector that * hosts this web application itself is never touched. *

+ * Upgrade protocols. A connector whose protocol handler is the HTTP/1.1 variant can carry upgrade protocols (the + * {@code UpgradeProtocol} interface; the only implementation shipped with Tomcat is the HTTP/2 one, + * {@code org.apache.coyote.http2.Http2Protocol}). An upgrade protocol is only referenced when the connector is + * initialised, so adding one does not change a running connector: it becomes active the next time the connector is + * (re)started (see the lifecycle operations). No live activation is attempted. + *

* JNDI naming resources. The server and every context have a {@code namingResources} node (a * {@code NamingResourcesImpl}) that holds the JNDI entries: {@code resource}, {@code resourceLink}, * {@code resourceEnvRef}, {@code environment}, {@code ejb}, {@code localEjb} and {@code service}. A server level node @@ -495,6 +505,7 @@ private Map build(Object component, String id, String type) { children.addAll(childrenOfListeners((LifecycleBase) context, id)); } else if (component instanceof Connector connector) { children.addAll(sslHostConfigChildren(connector, id)); + children.addAll(upgradeProtocolChildren(connector, id)); } else if (component instanceof SSLHostConfig sslHostConfig) { children.addAll(certificateChildren(sslHostConfig, id)); } else if (component instanceof CatalinaCluster cluster) { @@ -581,6 +592,46 @@ private static String certificateLabel(SSLHostConfigCertificate certificate) { } + /** + * The tree entries of the upgrade protocols of a connector, addressed by a positional index in the order they were + * added. + */ + private List> upgradeProtocolChildren(Connector connector, String parentId) { + List> result = new ArrayList<>(); + UpgradeProtocol[] upgradeProtocols = connector.findUpgradeProtocols(); + for (int i = 0; i < upgradeProtocols.length; i++) { + UpgradeProtocol upgradeProtocol = upgradeProtocols[i]; + Map entry = new LinkedHashMap<>(); + entry.put("id", parentId + "/upgradeProtocol/" + i); + entry.put("type", "upgradeProtocol"); + entry.put("className", upgradeProtocol.getClass().getName()); + entry.put("name", upgradeProtocolLabel(upgradeProtocol)); + entry.put("children", new ArrayList>()); + result.add(entry); + } + return result; + } + + + /** + * A human readable name for an upgrade protocol: the ALPN name (e.g. h2), then the HTTP upgrade name (e.g. h2c), + * then the simple name of the class. + */ + private static String upgradeProtocolLabel(UpgradeProtocol upgradeProtocol) { + String label = upgradeProtocol.getAlpnName(); + if (label == null || label.isEmpty()) { + label = upgradeProtocol.getHttpUpgradeName(false); + } + if (label == null || label.isEmpty()) { + label = upgradeProtocol.getHttpUpgradeName(true); + } + if (label == null || label.isEmpty()) { + label = upgradeProtocol.getClass().getSimpleName(); + } + return label; + } + + private List> childrenOfConnectors(StandardService service, String parentId) { List> result = new ArrayList<>(); Connector[] connectors = service.findConnectors(); @@ -593,7 +644,9 @@ private List> childrenOfConnectors(StandardService service, entry.put("className", connector.getClass().getName()); entry.put("name", connectorLabel(connector)); entry.put("state", connector.getState().toString()); - entry.put("children", sslHostConfigChildren(connector, id)); + List> connectorChildren = sslHostConfigChildren(connector, id); + connectorChildren.addAll(upgradeProtocolChildren(connector, id)); + entry.put("children", connectorChildren); result.add(entry); } return result; @@ -1018,6 +1071,9 @@ private static String displayName(Object component, String type) { if (component instanceof SSLHostConfigCertificate certificate) { return certificateLabel(certificate); } + if (component instanceof UpgradeProtocol upgradeProtocol) { + return upgradeProtocolLabel(upgradeProtocol); + } if (component instanceof ResourceBase entry) { return entry.getName(); } @@ -1343,6 +1399,51 @@ boolean isParam() { new ExplicitAttribute("sessionIdLength", "int", true, "The length of the generated session ids in bytes.")); + // The HTTP/2 upgrade protocol has no modeler descriptor either. Only the + // common, documented knobs are listed (like the cluster channel + // components). All changes take effect when the owning connector is + // (re)started, the same as the protocol itself. + private static final List HTTP2_PROTOCOL_ATTRIBUTES = List.of( + new ExplicitAttribute("readTimeout", "long", true, "The socket level read timeout in milliseconds."), + new ExplicitAttribute("writeTimeout", "long", true, "The socket level write timeout in milliseconds."), + new ExplicitAttribute("keepAliveTimeout", "long", true, "The keep alive timeout in milliseconds."), + new ExplicitAttribute("streamReadTimeout", "long", true, "The stream level read timeout in milliseconds."), + new ExplicitAttribute("streamWriteTimeout", "long", true, + "The stream level write timeout in milliseconds."), + new ExplicitAttribute("maxConcurrentStreams", "long", true, + "The maximum number of concurrent streams per connection."), + new ExplicitAttribute("maxConcurrentStreamExecution", "int", true, + "The maximum number of concurrently executing streams per connection."), + new ExplicitAttribute("initialWindowSize", "int", true, + "The initial window size advertised to the client in bytes."), + new ExplicitAttribute("useSendfile", "boolean", true, "Whether to use sendfile for file transfers."), + new ExplicitAttribute("allowSchemeMismatch", "boolean", true, + "Whether HTTP/2 streams may provide a scheme that does not match the transport."), + new ExplicitAttribute("maxHeaderCount", "int", true, "The maximum number of headers allowed per request."), + new ExplicitAttribute("maxHeaderSize", "int", false, + "The maximum size of request headers in bytes (set on the HTTP/1.1 protocol handler)."), + new ExplicitAttribute("maxTrailerCount", "int", true, + "The maximum number of trailer headers allowed per request."), + new ExplicitAttribute("maxTrailerSize", "int", false, + "The maximum size of trailer headers in bytes (set on the HTTP/1.1 protocol handler)."), + new ExplicitAttribute("overheadCountFactor", "int", true, + "The overhead count factor used for overhead frame tracking."), + new ExplicitAttribute("overheadResetFactor", "int", true, + "The overhead reset factor used for RST frame tracking."), + new ExplicitAttribute("overheadContinuationThreshold", "int", true, + "The payload size threshold for CONTINUATION frame overhead tracking in bytes."), + new ExplicitAttribute("overheadDataThreshold", "int", true, + "The payload size threshold for DATA frame overhead tracking in bytes."), + new ExplicitAttribute("overheadWindowUpdateThreshold", "int", true, + "The payload size threshold for WINDOW_UPDATE frame overhead tracking in bytes."), + new ExplicitAttribute("initiatePingDisabled", "boolean", true, + "Whether the periodic PING frames that keep the connection alive are disabled."), + new ExplicitAttribute("discardRequestsAndResponses", "boolean", true, + "Whether requests and responses are discarded after processing instead of being recycled."), + new ExplicitAttribute("drainTimeout", "long", true, + "The additional time in nanoseconds between the first and the final GOAWAY while a connection is drained.")); + + // --------------------------------- Cluster channel attributes // The sub components of a cluster channel (GroupChannel, the multicast @@ -1763,6 +1864,9 @@ private static List explicitAttributes(Object component, Stri if (component instanceof SessionIdGeneratorBase) { return SESSION_ID_GENERATOR_ATTRIBUTES; } + if (component instanceof Http2Protocol) { + return HTTP2_PROTOCOL_ATTRIBUTES; + } if (component instanceof GroupChannel) { return CHANNEL_ATTRIBUTES; } @@ -2107,6 +2211,18 @@ private NodeRef resolve(String id) throws ConfigException { parent = current; current = found; } + case "upgradeProtocol" -> { + if (!(current instanceof Connector connector)) { + throw notFound(); + } + UpgradeProtocol[] upgradeProtocols = connector.findUpgradeProtocols(); + int index = index(value); + if (index < 0 || index >= upgradeProtocols.length) { + throw notFound(); + } + parent = current; + current = upgradeProtocols[index]; + } case "certificate" -> { if (!(current instanceof SSLHostConfig hostConfig)) { throw notFound(); @@ -2496,6 +2612,8 @@ private NodeRef resolve(String id) throws ConfigException { type = "sslHostConfig"; } else if (current instanceof SSLHostConfigCertificate) { type = "certificate"; + } else if (current instanceof UpgradeProtocol) { + type = "upgradeProtocol"; } else if (current instanceof LifecycleListener) { type = "listener"; } else { @@ -2952,6 +3070,7 @@ private void addChild(HttpServletResponse response, Map body) th case "localEjb" -> addNamingEntry(response, parent, body, "localEjb"); case "serviceRef" -> addNamingEntry(response, parent, body, "serviceRef"); case "sslHostConfig" -> addSslHostConfig(response, parent, body); + case "upgradeProtocol" -> addUpgradeProtocol(response, parent, body); case "certificate" -> addCertificate(response, parent, body); case "cluster" -> addCluster(response, parent, body); case "clusterValve" -> addClusterValve(response, parent, body); @@ -4265,6 +4384,53 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< } + /** + * Add an upgrade protocol (e.g. the HTTP/2 one) to a connector through {@code AbstractHttp11Protocol + * .addUpgradeProtocol}. An upgrade protocol is only referenced when the connector is initialised, so the new + * instance does not take effect on a running connector: it becomes active the next time the connector is + * (re)started (see the lifecycle operations). No live activation is attempted. + */ + private void addUpgradeProtocol(HttpServletResponse response, NodeRef parent, Map body) + throws Exception { + + if (!(parent.component instanceof Connector connector)) { + throw badParent("upgradeProtocol"); + } + if (http11ProtocolHandler(connector) == null) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", + sm.getString("manager2.configUpgradeUnsupported", connector.getProtocolHandlerClassName())); + } + String className = string(body.get("className")); + if (className == null || className.isEmpty()) { + // The only UpgradeProtocol shipped with Tomcat is the HTTP/2 one. + className = "org.apache.coyote.http2.Http2Protocol"; + } + UpgradeProtocol upgradeProtocol; + try { + // Upgrade protocols are server level classes: never use the webapp + // class loader. + upgradeProtocol = (UpgradeProtocol) Class.forName(className, true, server.getClass().getClassLoader()) + .getConstructor().newInstance(); + } catch (Exception e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + sm.getString("manager2.configInvalidClass", className)); + } + // The protocol handler keeps the protocols in a plain list without + // checking for duplicates; a second protocol with the same name would + // be silently shadowed when the connector is next initialised, so + // reject it here. + String label = upgradeProtocolLabel(upgradeProtocol); + for (UpgradeProtocol existing : connector.findUpgradeProtocols()) { + if (label.equals(upgradeProtocolLabel(existing))) { + throw duplicate(label); + } + } + connector.addUpgradeProtocol(upgradeProtocol); + log(sm.getString("manager2.configAuditAdd", "upgradeProtocol", label)); + Api.ok(response, sm.getString("manager2.configUpgradeProtocolAdded", label)); + } + + /** * Add a certificate configuration to an SSL host configuration. On a running, TLS enabled connector the new * certificate is applied at once (the SSL context of the virtual host is re-created, which also validates the @@ -4358,11 +4524,11 @@ private static void setIfPresent(Object target, String name, Map /** - * The protocol handler of the connector when it is the HTTP/1.1 variant that supports TLS, otherwise {@code null} - * (for example an AJP connector). + * The protocol handler of the connector when it is the HTTP/1.1 variant, otherwise {@code null} (for example an AJP + * connector). */ @SuppressWarnings("rawtypes") - private static AbstractHttp11Protocol sslProtocolHandler(Connector connector) { + private static AbstractHttp11Protocol http11ProtocolHandler(Connector connector) { if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol http11) { return http11; } @@ -4370,6 +4536,16 @@ private static AbstractHttp11Protocol sslProtocolHandler(Connector connector) { } + /** + * The protocol handler of the connector when it is the HTTP/1.1 variant that supports TLS, otherwise {@code null} + * (for example an AJP connector). + */ + @SuppressWarnings("rawtypes") + private static AbstractHttp11Protocol sslProtocolHandler(Connector connector) { + return http11ProtocolHandler(connector); + } + + /** * Whether the connector's protocol handler has TLS enabled. */ @@ -4431,6 +4607,7 @@ private boolean isSelfConnector(Connector connector) { } + @SuppressWarnings("rawtypes") private void removeChild(HttpServletResponse response, Map body) throws Exception { String id = string(body.get("id")); @@ -4507,8 +4684,8 @@ private void removeChild(HttpServletResponse response, Map body) String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); if (Set.of("host", "context", "service", "engine", "connector", "executor", "wrapper", "valve", "sslHostConfig", - "realm", "cluster", "resource", "resourceLink", "resourceEnvRef", "environment", "ejb", "localEjb", - "serviceRef").contains(ref.type)) { + "upgradeProtocol", "realm", "cluster", "resource", "resourceLink", "resourceEnvRef", "environment", + "ejb", "localEjb", "serviceRef").contains(ref.type)) { String confirm = string(body.get("confirm")); if (!label.equals(confirm)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", @@ -4536,6 +4713,13 @@ private void removeChild(HttpServletResponse response, Map body) case "alias" -> ((Host) ref.component).removeAlias(ref.aliasValue); case "listener" -> ((LifecycleBase) ref.parent).removeLifecycleListener((LifecycleListener) ref.component); + case "upgradeProtocol" -> { + AbstractHttp11Protocol http11 = http11ProtocolHandler((Connector) ref.parent); + if (http11 == null) { + throw notFound(); + } + http11.removeUpgradeProtocol((UpgradeProtocol) ref.component); + } case "realm" -> removeRealm((Realm) ref.component, ref.parent); case "engine" -> ((StandardService) ref.parent).setContainer(null); case "service" -> server.removeService((Service) ref.component); @@ -4858,8 +5042,8 @@ private void startChecked(Lifecycle lifecycle, String label) throws ConfigExcept /** * Whether stopping or starting this component would interrupt access to this web application: the component is the * server itself, or the service, engine, host, context, connector or wrapper that route or host the requests of - * this web application. A {@code restart} of such a component remains possible (the client reconnects at the end - * of the operation). + * this web application. A {@code restart} of such a component remains possible (the client reconnects at the end of + * the operation). */ private boolean affectsSelf(NodeRef ref) { Object component = ref.component; diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties index a139dc349354..8d748707d9ac 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -100,6 +100,8 @@ manager2.configSslCertificateRequired=A running connector cannot be switched to manager2.configSslDefault=The default SSL host configuration of a running, TLS enabled connector cannot be removed while other configurations remain. manager2.configSslLastCertificate=The last certificate of the running, TLS enabled connector [{0}] cannot be removed. Stop the connector first. manager2.configSslReloadFailed=The attribute [{0}] of [{1}] was reverted: the TLS configuration of the connector does not accept it: {2} +manager2.configUpgradeUnsupported=The protocol handler [{0}] of this connector does not support upgrade protocols. +manager2.configUpgradeProtocolAdded=The upgrade protocol [{0}] has been added. Restart the connector to make it take effect. manager2.configStoreFailed=The configuration could not be written to server.xml. manager2.configStored=The configuration has been written to {0}. Backup: {1} manager2.configInvalidOp=The lifecycle operation [{0}] is not valid. diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java index 1d03c1591717..b82cc0e69c31 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -994,6 +994,160 @@ public void testSslHostConfig() throws Exception { } + @Test + public void testUpgradeProtocol() throws Exception { + setup(); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + String token = loginAndGetToken(client, "manager1"); + + // A dedicated service so that the test never touches the + // connector that hosts this web application itself. + String serviceId = "server/service/CatalinaH2"; + String connectorId = null; + int port = 0; + + try { + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"server\",\"type\":\"service\",\"name\":\"CatalinaH2\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + + port = freePort(); + request(client, "POST", MANAGER2 + "/api/config/child", token, "{\"parent\":\"" + serviceId + + "\",\"type\":\"connector\",\"protocol\":\"HTTP/1.1\",\"port\":" + port + "}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + connectorId = serviceId + "/connector/0"; + + // Guards --------------------------------------------------- + + // An upgrade protocol can only be added to a connector. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + serviceId + "/engine/CatalinaH2\"," + "\"type\":\"upgradeProtocol\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("BAD_PARENT")); + + // A class that is not an UpgradeProtocol is rejected. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"upgradeProtocol\"," + + "\"className\":\"java.lang.String\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_CLASS")); + + // Add ------------------------------------------------------ + + // Add with the default class (no className in the body): the + // only UpgradeProtocol shipped with Tomcat is the HTTP/2 one. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"upgradeProtocol\"}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + Assert.assertTrue(client.getResponseBody().contains("h2")); + + String upgradeProtocolId = connectorId + "/upgradeProtocol/0"; + + // The protocol is visible in the tree ... + Map tree = fetchTree(client); + Map connectorNode = findChildById(tree, connectorId); + Assert.assertNotNull("Expected the connector in the tree", connectorNode); + Map upNode = findChild(connectorNode, "upgradeProtocol", "h2"); + Assert.assertNotNull("Expected the upgrade protocol in the tree", upNode); + Assert.assertEquals(upgradeProtocolId, upNode.get("id")); + Assert.assertEquals("org.apache.coyote.http2.Http2Protocol", upNode.get("className")); + // ...and the node detail resolves (no lifecycle: no state). + Map detail = fetchNode(client, upgradeProtocolId); + Assert.assertEquals("upgradeProtocol", detail.get("type")); + Assert.assertEquals("h2", detail.get("name")); + Assert.assertEquals("org.apache.coyote.http2.Http2Protocol", detail.get("className")); + Assert.assertNull(detail.get("state")); + Assert.assertNull(detail.get("lifecycle")); + + // The node detail lists the HTTP/2 settings of the protocol + // (the class has no modeler descriptor; the list is + // explicit). + Assert.assertEquals(22, getList(detail, "properties").size()); + Assert.assertEquals(5000L, + ((Number) findProperty(detail, "readTimeout").get("value")).longValue()); + Assert.assertEquals(100L, + ((Number) findProperty(detail, "maxConcurrentStreams").get("value")).longValue()); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "useSendfile").get("writable")); + Assert.assertEquals(Boolean.TRUE, findProperty(detail, "maxConcurrentStreams").get("writable")); + // The header/trailer sizes come from the HTTP/1.1 protocol + // handler: they are read-only. + Assert.assertEquals(Boolean.FALSE, findProperty(detail, "maxHeaderSize").get("writable")); + Assert.assertEquals(Boolean.FALSE, findProperty(detail, "maxTrailerSize").get("writable")); + + // A setting can be updated; like the protocol itself it + // takes effect when the connector is restarted. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + upgradeProtocolId + "\",\"name\":\"maxConcurrentStreams\",\"value\":500}", 200); + Assert.assertTrue(client.getResponseBody().contains("\"ok\":true")); + detail = fetchNode(client, upgradeProtocolId); + Assert.assertEquals(500L, + ((Number) findProperty(detail, "maxConcurrentStreams").get("value")).longValue()); + + // Guards: a read-only and an unknown attribute are refused, + // as is a value that does not convert to the attribute type. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + upgradeProtocolId + "\",\"name\":\"maxHeaderSize\",\"value\":100}", 400); + Assert.assertTrue(client.getResponseBody().contains("READ_ONLY")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + upgradeProtocolId + "\",\"name\":\"bogus\",\"value\":1}", 404); + Assert.assertTrue(client.getResponseBody().contains("ATTRIBUTE_NOT_FOUND")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + upgradeProtocolId + "\",\"name\":\"maxConcurrentStreams\",\"value\":\"abc\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("INVALID_VALUE")); + + // The running connector is unchanged: the protocol (and its + // settings) only take effect when the connector is + // restarted, so it keeps serving plain HTTP. + int status = httpGetPlain(port, "/"); + Assert.assertTrue("Expected plain HTTP, got " + status, status >= 200 && status < 600); + + // A second protocol with the same name is rejected. + request(client, "POST", MANAGER2 + "/api/config/child", token, + "{\"parent\":\"" + connectorId + "\",\"type\":\"upgradeProtocol\"}", 409); + Assert.assertTrue(client.getResponseBody().contains("DUPLICATE")); + + // The upgrade protocol is part of the stored server.xml, + // including the changed setting (attributes that still have + // their default value are not written). + request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); + String xml = (String) parseObject(client.getResponseBody()).get("xml"); + Assert.assertTrue(xml.contains(" Date: Tue, 15 Sep 2026 21:53:41 +0200 Subject: [PATCH 05/17] Fix some properties issues for resources --- .../tomcat/manager2/ConfigApiServlet.java | 17 +++++++- .../tomcat/manager2/TestManager2Config.java | 26 ++++++++++++ .../manager2/webapp/js/pages/configuration.js | 42 ++++++++++++++----- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 1cdc0de5d6f5..cba5d26dacf9 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -2739,7 +2739,22 @@ private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, sm.getString("manager2.configReadOnly", name)); } - Object value = param ? string(body.get("value")) : convert(attribute.getType(), body.get("value")); + Object value; + if (attribute.isParam()) { + // Parameters (the free form parameters of a JNDI entry and + // the typed factory options) are stored as string properties + // of the entry: keep the raw string form (an empty value + // removes the parameter, see setExplicitValue) and validate + // it against the declared type of the option (free form + // parameters are strings and always pass). + String paramValue = string(body.get("value")); + if (paramValue != null && !paramValue.isEmpty()) { + validateParamValue(name, paramValue, attribute.getType()); + } + value = paramValue; + } else { + value = convert(attribute.getType(), body.get("value")); + } Object oldValue = param ? null : readExplicitValue(component, attribute); setExplicitValue(component, attribute, value); diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java index b82cc0e69c31..317c546a1121 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -1713,6 +1713,32 @@ public void testNamingResources() throws Exception { Assert.assertEquals("false", findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); Assert.assertNotNull(getTomcatInstance().getServer().getGlobalNamingContext().lookup("UserDatabaseTest")); + // A boolean factory option also accepts the JSON boolean the + // checkbox of the form sends (it is stored as its string + // form), and a value that does not fit the declared type of + // the option is rejected (the stored value is unchanged). + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"readonly\",\"value\":true}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Assert.assertEquals("true", findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"readonly\",\"value\":\"not-a-boolean\"}", 400); + Assert.assertTrue(client.getResponseBody().contains("SET_FAILED")); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Assert.assertEquals("true", findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); + + // Removing a typed factory option clears the parameter (it + // must not be stored as the string "false"): the option is + // listed again with no value and can be set again. + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"readonly\",\"value\":\"\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Assert.assertNull(findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); + request(client, "POST", MANAGER2 + "/api/config/attribute", token, + "{\"id\":\"" + dbId + "\",\"name\":\"readonly\",\"value\":\"true\"}", 200); + request(client, "GET", MANAGER2 + "/api/config/node/" + dbId, null, null, 200); + Assert.assertEquals("true", findProperty(parseObject(client.getResponseBody()), "readonly").get("value")); + // Renaming requires a type-to-confirm and rebinds the resource. request(client, "POST", MANAGER2 + "/api/config/attribute", token, "{\"id\":\"" + dbId + "\",\"name\":\"name\"," + "\"value\":\"UserDatabaseRenamed\"}", 400); diff --git a/modules/manager2/webapp/js/pages/configuration.js b/modules/manager2/webapp/js/pages/configuration.js index d3c0339b6159..37852978bb2a 100644 --- a/modules/manager2/webapp/js/pages/configuration.js +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -319,20 +319,35 @@ export async function configuration(container) { onclick: () => applyProperty(node, p, input), }, 'Apply')); if (p.param) { - edit.append(el('button', { - type: 'button', class: 'btn btn-sm btn-danger', - title: 'Remove this parameter', - onclick: () => removeParameter(node, p), - }, 'Remove')); + if (paramSet(p)) { + edit.append(el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + title: 'Remove this parameter', + onclick: () => removeParameter(node, p), + }, 'Remove')); + } else { + // An unset parameter: nothing to remove. Applying a value adds + // the parameter (or, for a boolean, unchecking it is a no-op). + edit.append(el('span', { class: 'config-param-hint' }, 'not set')); + } } return el('div', { class: 'config-prop' }, label, edit); } + // Whether a parameter of a JNDI entry is set (has a non empty value). + function paramSet(p) { + return p.value !== null && p.value !== undefined && String(p.value) !== ''; + } + function buildInput(p) { const t = p.type; if (t === 'boolean') { const box = el('input', { type: 'checkbox', class: 'config-check' }); - box.checked = Boolean(p.value); + // A parameter is stored as the string "true" or "false": only + // "true" checks the box (Boolean("false") would be wrongly + // truthy). A real boolean attribute is stored as an actual + // boolean. + box.checked = p.param ? p.value === 'true' : Boolean(p.value); return box; } if (NUMERIC_TYPES.has(t)) { @@ -370,8 +385,15 @@ export async function configuration(container) { async function applyProperty(node, p, input) { let value = readInput(input, p); - // Guard against no-op writes. - if (sameValue(value, p.value)) { + // Guard against no-op writes. A boolean parameter is a toggle: the + // effective state is whether it is set to "true", so the no-op + // check is on the checkbox state. That also means applying while + // the box is unchecked does not store "false" for an absent + // parameter, and re-applying an already stored "false" is a no-op. + const noOp = p.param && p.type === 'boolean' + ? Boolean(value) === (p.value === 'true') + : sameValue(value, p.value); + if (noOp) { toast('No changes to apply.', 'info'); return; } @@ -437,8 +459,8 @@ export async function configuration(container) { // Remove a generic parameter from a JNDI entry by clearing it: the server // drops parameters whose value is empty. async function removeParameter(node, p) { - if (p.value === null || p.value === undefined || p.value === '') { - toast('This parameter is already empty.', 'info'); + if (!paramSet(p)) { + toast('This parameter is not set.', 'info'); return; } try { From 4d34d87e76d6ed07696672d45c2a4f254f1e7892 Mon Sep 17 00:00:00 2001 From: remm Date: Wed, 16 Sep 2026 16:13:38 +0200 Subject: [PATCH 06/17] Remove patch --- ssl.patch | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 ssl.patch diff --git a/ssl.patch b/ssl.patch deleted file mode 100644 index a0758727136c..000000000000 --- a/ssl.patch +++ /dev/null @@ -1,34 +0,0 @@ -diff --git a/java/org/apache/tomcat/util/net/AbstractEndpoint.java b/java/org/apache/tomcat/util/net/AbstractEndpoint.java -index b0fe7ff28e..158302b320 100644 ---- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java -+++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java -@@ -499,7 +499,12 @@ public abstract class AbstractEndpoint { - // internally because they are used as keys in a ConcurrentMap where - // keys are compared in a case-sensitive manner. - String hostNameLower = hostName.toLowerCase(Locale.ENGLISH); -- if (hostNameLower.equals(getDefaultSSLHostConfigName())) { -+ // The default host configuration is the fallback for handshakes -+ // without a matching SNI name, so it cannot be removed while the -+ // endpoint is still serving TLS. Once TLS is switched off (for -+ // example to remove the last remaining host configuration) the -+ // guard no longer applies. -+ if (isSSLEnabled() && hostNameLower.equals(getDefaultSSLHostConfigName())) { - throw new IllegalArgumentException(sm.getString("endpoint.removeDefaultSslHostConfig", hostName)); - } - SSLHostConfig sslHostConfig = sslHostConfigs.remove(hostNameLower); -@@ -827,7 +832,14 @@ public abstract class AbstractEndpoint { - * - * @throws Exception If an error occurs while initializing SSL - */ -- protected void initialiseSsl() throws Exception { -+ /** -+ * Initialize the SSL implementation and (re-)create the SSL context -+ * of every SSL host configuration. Called from {@code bind()} but -+ * also made available to components that switch an already bound, -+ * running endpoint to TLS after the initial bind (which is when the -+ * SSL implementation and contexts are validated and created). -+ */ -+ public void initialiseSsl() throws Exception { - if (isSSLEnabled()) { - sslImplementation = SSLImplementation.getInstance(getSslImplementationName()); - From 6463f5a91a5151acac6fd894e7ad1b72908bde69 Mon Sep 17 00:00:00 2001 From: remm Date: Wed, 16 Sep 2026 17:15:32 +0200 Subject: [PATCH 07/17] Improve webapp reactive behavior significantly Tested on a phone in portrait or landscape mode. The only really not so nice item is the icon row. --- modules/manager2/manager2-design.md | 41 +++- .../apache/tomcat/manager2/HomeServlet.java | 14 +- .../tomcat/manager2/TestManager2Webapp.java | 7 + modules/manager2/webapp/css/manager2.css | 230 +++++++++++++++++- modules/manager2/webapp/index.html | 1 + modules/manager2/webapp/js/logviewer.js | 1 + modules/manager2/webapp/js/main.js | 19 +- modules/manager2/webapp/js/pages/apps.js | 82 +++---- .../manager2/webapp/js/pages/configuration.js | 66 +++-- .../manager2/webapp/js/pages/diagnostics.js | 4 +- modules/manager2/webapp/js/pages/hosts.js | 34 +-- .../manager2/webapp/js/pages/monitoring.js | 22 +- modules/manager2/webapp/js/pages/users.js | 33 +-- modules/manager2/webapp/js/ui.js | 133 +++++++++- 14 files changed, 551 insertions(+), 136 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 8caf17794db0..168313fbbeb0 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -607,8 +607,20 @@ server-side timestamp, so restarts and clock skew are handled. - System font stack; 12-column grid; 8 px spacing scale; single accent colour; states (success/warning/danger) with colour + icon (never colour alone). -- Responsive: tables convert to stacked cards below 768 px; charts - re-flow to a single column; actions move into an overflow menu. +- Responsive, below 768 px (and on landscape phones of any width up to + 932 px, via a `max-height` media): management tables (applications, + hosts, users, sessions, connectors, servlets, …) convert to stacked + cards, each cell labelled with its column header; high-volume tables + (server logs, access log, active workers) size to their content so + columns are never squeezed, with the first column pinned while the table + scrolls horizontally — except on the log pages in portrait, where the + wide Time column would dominate the viewport, so the whole table scrolls. + The log pages' long text columns (messages, requests) are given most of + the viewport (90vw portrait, 100vw landscape) because with + `table-layout:auto` any extra table width flows into the only wrapping + column, keeping rows to one or two lines for information density. + Charts re-flow to a single column; row actions move into an overflow + (kebab) menu; form fields and modals go full width. - Accessibility (WCAG 2.1 AA): semantic landmarks, visible focus states, keyboard-operable modals/drawers (focus trap, `Esc` closes), `aria-live="polite"` toasts, live chart updates announced at reduced @@ -725,8 +737,15 @@ saved request (sending the browser to a CSS file after login). Instead: - `HomeServlet` gates the SPA entry point (`/`) and the SPA deep-link routes (`/apps`, `/hosts`, `/configuration`, `/users`, `/monitoring`, `/diagnostics`, `/logs`, `/access-log`, `/apps/*`): it forwards - unauthenticated visitors to the login page and authenticated users to the - shell, preserving the requested URL so deep links survive a reload. + unauthenticated visitors to the login page and authenticated users get the + shell rendered from `index.html` as a template, preserving the requested + URL so deep links survive a reload. The template rendering (rather than a + plain forward to the static file) injects a `` element (the + `` placeholder, see `Html`): a *multi-segment* deep + link such as `/apps/localhost/myapp` would otherwise make the browser + resolve the shell's relative asset URLs (`js/main.js`, `css/manager2.css`) + against the deep path (`/apps/localhost/js/main.js`), which the server + answers with the shell HTML and the browser then refuses as a script. - The context root *without* a trailing slash (e.g. `/manager2`) is redirected (302) to the trailing-slash form by `HomeServlet`. Without the redirect the browser would resolve the page's relative URLs (`css/*`, @@ -1120,3 +1139,17 @@ deploy upload, live chart behaviour, mobile widths); the JS is small enough that a lint pass (`--check` via a CI node step, optional) plus the integration tests above gives adequate coverage without a JS test harness. + +Mobile checklist (portrait 360/390/414 px and landscape 667/812/932 px, +both themes): no page-level horizontal overflow; management tables render +as labelled stacked cards and row actions collapse into the kebab menu +(kebab opens, closes on outside tap and `Esc`, disabled actions stay +disabled); high-volume tables (logs, access log, workers) scroll +horizontally with the first column pinned and long values wrapping, not +squeezed; tabs scroll when crowded; page-head, log and diagnostics +controls go full width; modals show stacked full-width buttons; toasts +appear above the bottom nav; the bottom nav keeps all nine items with +labels truncated inside their slot; inputs are 16 px at ≤480 px (no iOS +focus zoom); the Configuration detail scrolls into view after a tree +selection; a multi-segment deep link (e.g. an application detail) reloads +to the correct page (the base-element fix above). diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java index 7afc8784c43d..37b7d0a0af01 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java @@ -28,8 +28,11 @@ * Serves the SPA entry point and the SPA deep-link routes (e.g. {@code /apps}, {@code /hosts}, {@code /monitoring}, * {@code /diagnostics}) which have no server-side resource of their own. *

- * Unauthenticated visitors are forwarded to the login page. Authenticated users are forwarded to the SPA shell - * ({@code /index.html}) so that the browser keeps the requested URL (deep links survive a reload). + * Unauthenticated visitors are forwarded to the login page. Authenticated users get the SPA shell (rendered from + * {@code /index.html}, with the {@code } element injected) so that the browser keeps the requested URL (deep + * links survive a reload). Rendering the shell as a template - instead of forwarding to the static file - is what + * makes multi-segment deep links (e.g. {@code /apps/localhost/myapp}) work: without a base element the browser would + * resolve the shell's relative asset URLs against the deep path. *

* The SPA shell itself is deliberately not protected with a security constraint: a constraint on {@code /} * would match every request in the context (including the CSS and JS that the login page needs) and would poison the @@ -61,7 +64,12 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } if (request.getUserPrincipal() != null) { - request.getRequestDispatcher("/index.html").forward(request, response); + String template = Html.readTemplate(getServletContext(), "/index.html"); + if (template == null) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SPA shell missing"); + return; + } + Html.render(request, response, template); } else { request.getRequestDispatcher("/login").forward(request, response); } diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java index c52f6034fc59..a57541bd0463 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -270,6 +270,13 @@ public void testAuthenticatedRootServesShell() throws Exception { requestRaw(client, "GET", MANAGER2 + "/apps", 200); Assert.assertTrue(client.getResponseBody().contains("shell-main")); + // A multi-segment deep link (e.g. an application detail page) must also + // serve the shell, with a base element so the shell's relative asset URLs + // resolve against the context instead of the deep path. + requestRaw(client, "GET", MANAGER2 + "/apps/localhost/myapp", 200); + Assert.assertTrue(client.getResponseBody().contains("shell-main")); + Assert.assertTrue(client.getResponseBody().contains("")); + client.disconnect(); } diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index c4df46f0f817..99bb3fd15504 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -179,6 +179,9 @@ button { font: inherit; color: inherit; cursor: pointer; } } .topbar-actions { display: flex; align-items: center; gap: var(--space-2); } +/* The logout button carries an icon that is only shown on narrow screens + (where the text label is hidden). */ +#logout svg { display: none; fill: currentColor; } .icon-btn { display: inline-flex; align-items: center; justify-content: center; @@ -232,6 +235,8 @@ button { font: inherit; color: inherit; cursor: pointer; } flex-wrap: wrap; } .page-head p { margin: 0; color: var(--text-soft); font-size: 13px; } +.page-head h1 { overflow-wrap: anywhere; } +.head-spacer { flex: 1; } /* ============================ Cards / grid ============================= */ @@ -293,6 +298,20 @@ table.data .muted { color: var(--text-faint); } .row-actions { display: flex; gap: 6px; flex-wrap: wrap; } .row-actions .row-actions-sep { align-self: stretch; width: 1px; margin: 2px 3px; background: var(--border); } +/* Overflow (kebab) button of a .row-actions.has-kebab list. It is hidden + on wide screens, where the individual buttons are shown instead. */ +.row-actions-kebab { + display: none; + align-items: center; justify-content: center; + width: 34px; height: 34px; + background: var(--bg-card); + border: 1px solid var(--border-strong); + border-radius: var(--radius-s); + color: var(--text-soft); +} +.row-actions-kebab:hover { background: var(--bg-hover); color: var(--text); } +.row-actions-kebab svg { fill: currentColor; } + /* ============================ Log pages ================================= */ .muted { color: var(--text-faint); } @@ -311,6 +330,12 @@ table.data .muted { color: var(--text-faint); } .log-table table.data td { overflow-wrap: anywhere; max-width: 640px; } .log-table table.data code { word-break: break-all; } +/* Fixed-width inline controls (diagnostics, app detail). On narrow screens + these drop to full width (see the responsive section). */ +.tls-host { width: 280px; max-width: 100%; } +.res-type { width: 220px; max-width: 100%; } +.expire-idle-input { width: 130px; max-width: 100%; } + /* ============================ Users page =============================== */ .users-page .card { margin-bottom: var(--space-4); } @@ -429,8 +454,9 @@ textarea { resize: vertical; min-height: 90px; font-family: var(--mono); font-si /* ============================ Tabs ===================================== */ -.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: var(--space-4); } +.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: var(--space-4); overflow-x: auto; } .tab { + flex: none; padding: 9px 14px; background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--text-soft); @@ -486,6 +512,39 @@ textarea { resize: vertical; min-height: 90px; font-family: var(--mono); font-si } .drawer-body { padding: var(--space-5); overflow-y: auto; } +/* ============================ Menu =================================== */ + +.menu-backdrop { position: fixed; inset: 0; z-index: 150; } +.menu { + position: fixed; + z-index: 151; + min-width: 150px; + max-width: min(260px, calc(100vw - 16px)); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-s); + box-shadow: var(--shadow-2); + padding: 4px; + display: flex; flex-direction: column; gap: 2px; +} +.menu-item { + display: flex; align-items: center; + width: 100%; + padding: 9px 10px; + background: transparent; + border: none; border-radius: 4px; + color: var(--text); + font-size: 13.5px; font-weight: 550; + text-align: left; + white-space: nowrap; +} +.menu-item:hover { background: var(--bg-hover); } +.menu-item:disabled { opacity: 0.5; cursor: not-allowed; } +.menu-item.danger { color: var(--danger); } +.menu-item.danger:hover:not(:disabled) { background: var(--danger-soft); } +.menu-item.primary { color: var(--accent-text); } +.menu-item.primary:hover:not(:disabled) { background: var(--accent-soft); } + /* ============================ Toasts =================================== */ .toast-region { @@ -821,3 +880,172 @@ pre.block { .kv dt { margin-top: 8px; } h1 { font-size: 19px; } } + +/* ============================ Responsive: phone ======================== */ + +@media (max-width: 768px) { + /* Page head: title first, then full width action buttons */ + .page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); } + .page-head .btn { width: 100%; } + .head-spacer { display: none; } + + /* Tables: the opt-in tables render as stacked cards (one row per card, + each cell labelled with its column). High volume tables stay + scrollable and pin their first column instead. */ + .table-wrap.stackable table.data, + .table-wrap.stackable table.data tbody, + .table-wrap.stackable table.data tr, + .table-wrap.stackable table.data td { display: block; width: 100%; } + .table-wrap.stackable thead { display: none; } + .table-wrap.stackable tbody tr { + border: 1px solid var(--border); + border-radius: var(--radius-s); + margin-bottom: var(--space-2); + background: var(--bg-card); + box-shadow: var(--shadow-1); + overflow: hidden; + } + /* Note: the selector must repeat "table.data" to keep a specificity at + least as high as the display:block rule above. */ + .table-wrap.stackable table.data td { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: right; + } + .table-wrap.stackable td:last-child { border-bottom: none; } + .table-wrap.stackable td::before { + content: attr(data-label); + flex: none; + max-width: 45%; + text-align: left; + font-size: 11.5px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; + color: var(--text-faint); + } + /* Cells without a label (e.g. the row checkbox) are dropped from the + card; the empty-state cell stays visible. */ + .table-wrap.stackable table.data td:not([data-label]):not(.empty) { display: none; } + .table-wrap.stackable table.data td.empty { display: flex; justify-content: center; } + .table-wrap.stackable table.data td code, + .table-wrap.stackable table.data td .mono { overflow-wrap: anywhere; } + + /* Row actions collapse into a kebab menu */ + .row-actions.has-kebab > :not(.row-actions-kebab) { display: none !important; } + .row-actions.has-kebab .row-actions-kebab { display: inline-flex; min-width: 44px; min-height: 44px; } + + /* Log page controls stack */ + .log-controls { flex-direction: column; align-items: stretch; gap: var(--space-3); } + .log-controls .field { width: 100%; } + .log-field-btn .btn { width: 100%; } + .log-filters { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } + .log-filters .log-field-search { grid-column: 1 / -1; } + + /* Inline controls go full width */ + .tls-host, .res-type, .expire-idle-input { width: 100%; } + .row-actions .tls-host + .btn, + .row-actions .res-type + .btn { width: 100%; } + + /* Configuration page */ + .config-tree { max-height: 260px; } + .config-prop-edit { flex-wrap: wrap; } + .config-prop-edit .config-input { flex: 1 1 100%; } + .config-detail-card { scroll-margin-top: var(--space-3); } + + /* Modals: full width stacked actions */ + .modal-actions { flex-direction: column-reverse; } + .modal-actions .btn { width: 100%; } + + /* Toasts render above the bottom nav */ + .toast-region { bottom: calc(var(--bottomnav-h) + var(--space-4)); } + + /* Bottom nav: keep the labels inside their slot */ + .nav-item { min-width: 0; } + .nav-item .nav-label { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .icon-btn { min-width: 44px; min-height: 44px; } +} + +/* Scrollable tables on phones. (max-width: 768px) covers portrait phones; + the landscape media adds the wider landscape phones (769-932 px) whose + shell is still desktop-sized but which need the same table treatment. */ +@media (max-width: 768px), (orientation: landscape) and (max-height: 520px) { + /* Size to content (floored at the container width) so columns are not + squeezed, and keep cells on one line. */ + .table-wrap:not(.stackable) table.data { + width: auto; + min-width: 100%; + } + /* Pin the first column while the table scrolls horizontally. */ + .table-wrap:not(.stackable) table.data th:first-child, + .table-wrap:not(.stackable) table.data td:first-child { + position: sticky; + left: 0; + z-index: 1; + background: var(--bg-card); + box-shadow: 1px 0 0 var(--border); + } + .table-wrap:not(.stackable) table.data th:first-child { z-index: 2; } + .table-wrap:not(.stackable) tbody tr:hover td:first-child { background: var(--bg-hover); } + .table-wrap:not(.stackable) table.data td { white-space: nowrap; } + /* Long text columns (log messages, requests) wrap within a capped + width instead of making the table extremely wide. min-width is what + actually steers table-layout:auto: width/max-width alone would let + the layout squeeze the column and the text would wrap at a few + characters per line. With table-layout:auto any table width beyond + the sum of the column minimums flows into these wrap-able columns, + so a larger min width directly shortens the rows. */ + .table-wrap:not(.stackable) table.data td.wide { + white-space: normal; + overflow-wrap: anywhere; + min-width: 60vw; + } + .table-wrap code { overflow-wrap: anywhere; } +} + +/* The log pages are information dense. In portrait the pinned Time column + would dominate the viewport and leave a sliver for the scrolling + content, so let the whole table scroll. And give the message/request + column most of the viewport so typical lines wrap to at most two rows; + max-width:none drops the 640 px desktop cell cap that would starve the + column again. */ +@media (orientation: portrait) and (max-width: 600px) { + .table-wrap.log-table:not(.stackable) table.data th:first-child, + .table-wrap.log-table:not(.stackable) table.data td:first-child { + position: static; + background: transparent; + box-shadow: none; + } + .table-wrap.log-table table.data td.wide { min-width: 90vw; max-width: none; } +} + +/* Landscape phones: even more of the viewport for the message/request + column so rows stay one or two lines tall. */ +@media (orientation: landscape) and (max-height: 520px) { + .table-wrap.log-table table.data td.wide { min-width: 100vw; max-width: none; } +} + +@media (max-width: 480px) { + .content { padding: var(--space-4) var(--space-3) calc(var(--bottomnav-h) + var(--space-4)); } + .card { padding: var(--space-4); } + .topbar { padding: 0 var(--space-3); gap: var(--space-2); } + .tab { padding: 9px 10px; } + #logout svg { display: block; } + #logout .logout-label { display: none; } + /* 16 px controls stop iOS Safari from zooming in on focus */ + input[type="text"], input[type="password"], input[type="number"], select, textarea { font-size: 16px; } +} + +/* Compact phones held in landscape */ +@media (orientation: landscape) and (max-width: 768px) and (max-height: 480px) { + .content { padding-top: var(--space-3); padding-bottom: calc(var(--bottomnav-h) + var(--space-3)); } + .card { margin-bottom: var(--space-3); } + .page-head { margin-bottom: var(--space-4); } +} diff --git a/modules/manager2/webapp/index.html b/modules/manager2/webapp/index.html index a6112c8942d2..238ac608700e 100644 --- a/modules/manager2/webapp/index.html +++ b/modules/manager2/webapp/index.html @@ -21,6 +21,7 @@ Tomcat Manager + diff --git a/modules/manager2/webapp/js/logviewer.js b/modules/manager2/webapp/js/logviewer.js index e1d90fd0eb79..dcd6a70bf82e 100644 --- a/modules/manager2/webapp/js/logviewer.js +++ b/modules/manager2/webapp/js/logviewer.js @@ -138,6 +138,7 @@ function columnsFor(fields) { label: def.label || key, numeric: def.numeric ? true : null, muted: !def.render && !def.numeric ? true : null, + wide: def.wide ? true : null, render: def.render ? def.render : def.mono diff --git a/modules/manager2/webapp/js/main.js b/modules/manager2/webapp/js/main.js index 0617135318be..6980b6d8c464 100644 --- a/modules/manager2/webapp/js/main.js +++ b/modules/manager2/webapp/js/main.js @@ -17,7 +17,7 @@ import { BASE, get } from './api.js'; import { register, setNotFound, render, setNavUpdater } from './router.js'; -import { el, clear, svgPath, toast, formatDuration } from './ui.js'; +import { el, clear, icon, svgPath, toast, formatDuration } from './ui.js'; import { dashboard } from './pages/dashboard.js'; import { apps, appDetail } from './pages/apps.js'; import { hosts } from './pages/hosts.js'; @@ -103,7 +103,12 @@ function buildNav() { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', svgPath(item.icon)); svg.append(path); - btn.append(svg, document.createTextNode(item.label)); + // The label is a span so that it can be truncated on narrow screens + // (see the bottom nav rules in the CSS). + const label = document.createElement('span'); + label.className = 'nav-label'; + label.textContent = item.label; + btn.append(svg, label); btn.addEventListener('click', () => { window.history.pushState({}, '', BASE + item.route); render(); @@ -152,6 +157,16 @@ async function boot() { buildNav(); setNavUpdater(updateNav); + // Rebuild the logout button as icon + label: on narrow screens the label + // is hidden and the icon takes its place (see the CSS). + const logoutBtn = document.getElementById('logout'); + logoutBtn.setAttribute('aria-label', 'Log out'); + const logoutLabel = document.createElement('span'); + logoutLabel.className = 'logout-label'; + logoutLabel.textContent = logoutBtn.textContent; + logoutBtn.textContent = ''; + logoutBtn.append(icon('logout', 18), logoutLabel); + document.getElementById('logout').addEventListener('click', async () => { try { await fetch(BASE + '/logout', { method: 'POST', credentials: 'same-origin' }); diff --git a/modules/manager2/webapp/js/pages/apps.js b/modules/manager2/webapp/js/pages/apps.js index 1b33ff1f70a7..fd53b14a7bc5 100644 --- a/modules/manager2/webapp/js/pages/apps.js +++ b/modules/manager2/webapp/js/pages/apps.js @@ -16,7 +16,7 @@ */ import { BASE, api, getCsrfToken, setCsrfToken } from '../api.js'; -import { el, clear, table, stateBadge, toast, modal, confirm, drawer, +import { el, clear, table, stateBadge, toast, modal, confirm, drawer, actionMenu, formatTimestamp, formatDuration } from '../ui.js'; /** @@ -43,7 +43,7 @@ export async function apps(container) { el('div', { class: 'page-head' }, el('h1', {}, 'Applications'), el('p', {}, 'Deploy, start, stop, reload and undeploy web applications.'), - el('span', { style: 'flex:1' }), + el('span', { class: 'head-spacer' }), el('button', { type: 'button', class: 'btn btn-primary', onclick: () => deployModal() }, 'Deploy application'))); container.append(view); @@ -83,32 +83,25 @@ export async function apps(container) { }, { key: 'actions', label: 'Actions', - render: (a) => el('div', { class: 'row-actions' }, + render: (a) => actionMenu([ a.available - ? el('button', { - type: 'button', class: 'btn btn-sm', - onclick: (e) => { e.stopPropagation(); lifecycle(a, 'stop'); }, - }, 'Stop') - : el('button', { - type: 'button', class: 'btn btn-sm btn-primary', - onclick: (e) => { e.stopPropagation(); lifecycle(a, 'start'); }, - }, 'Start'), - el('button', { - type: 'button', class: 'btn btn-sm', disabled: !a.available, - onclick: (e) => { e.stopPropagation(); lifecycle(a, 'reload'); }, - }, 'Reload'), - el('button', { - type: 'button', class: 'btn btn-sm btn-danger', disabled: a.self, + ? { label: 'Stop', onclick: () => lifecycle(a, 'stop') } + : { label: 'Start', class: 'btn-primary', onclick: () => lifecycle(a, 'start') }, + { label: 'Reload', disabled: !a.available, onclick: () => lifecycle(a, 'reload') }, + { + label: 'Undeploy', class: 'btn-danger', disabled: a.self, title: a.self ? 'Cannot undeploy the manager itself' : 'Undeploy', - onclick: (e) => { e.stopPropagation(); undeploy(a); }, - }, 'Undeploy')), - }], + onclick: () => undeploy(a), + }, + ]), + }], rows: data.apps, onRowClick: (a) => { window.history.pushState({}, '', BASE + appUrl(a.host, a.path)); window.dispatchEvent(new PopStateEvent('popstate')); }, empty: 'No applications deployed', + stackable: true, })); } @@ -422,7 +415,7 @@ export async function appDetail(container, params) { const controls = el('div', { class: 'row-actions', style: 'margin-bottom:12px;' }, el('input', { type: 'number', id: 'expire-idle', min: '0', placeholder: 'idle seconds', - style: 'width:130px;', + class: 'expire-idle-input', }), el('button', { type: 'button', class: 'btn btn-sm', @@ -441,7 +434,7 @@ export async function appDetail(container, params) { } }, }, 'Expire idle'), - el('span', { style: 'flex:1' }), + el('span', { class: 'head-spacer' }), el('button', { type: 'button', class: 'btn btn-sm btn-danger', onclick: async () => { @@ -523,21 +516,22 @@ export async function appDetail(container, params) { s.active ? 'active' : 'proxy'), }, ], - rows: data.sessions, - sortKey: data.sort || sortKey, - sortAsc: data.order === 'ASC', - onSort: (key) => { - if (sortKey === key) { - asc = !asc; - } else { - sortKey = key; - asc = true; - } - loadSessionTable(sortKey, asc); - }, - onRowClick: (s) => sessionDrawer(s), - empty: 'No sessions', - })); + rows: data.sessions, + sortKey: data.sort || sortKey, + sortAsc: data.order === 'ASC', + onSort: (key) => { + if (sortKey === key) { + asc = !asc; + } else { + sortKey = key; + asc = true; + } + loadSessionTable(sortKey, asc); + }, + onRowClick: (s) => sessionDrawer(s), + empty: 'No sessions', + stackable: true, + })); } function sessionDrawer(session) { @@ -657,7 +651,7 @@ export async function appDetail(container, params) { pane.append(el('div', { class: 'card' }, el('h3', {}, 'Servlets'), - el('div', { class: 'table-wrap' }, + el('div', { class: 'table-wrap stackable' }, el('table', { class: 'data' }, el('thead', {}, el('tr', {}, el('th', {}, 'Name'), @@ -667,12 +661,12 @@ export async function appDetail(container, params) { el('th', { class: 'num' }, 'Processing time'), el('th', { class: 'num' }, 'Max time'))), el('tbody', {}, (data.wrappers || []).map((w) => el('tr', {}, - el('td', {}, el('code', {}, w.name)), - el('td', { class: 'muted' }, (w.mappings || []).join(', ')), - el('td', { class: 'num' }, String(w.requestCount)), - el('td', { class: 'num' }, String(w.errorCount)), - el('td', { class: 'num' }, formatDuration(w.processingTime)), - el('td', { class: 'num' }, formatDuration(w.maxTime))))))))); + el('td', { 'data-label': 'Name' }, el('code', {}, w.name)), + el('td', { class: 'muted', 'data-label': 'Mappings' }, (w.mappings || []).join(', ')), + el('td', { class: 'num', 'data-label': 'Requests' }, String(w.requestCount)), + el('td', { class: 'num', 'data-label': 'Errors' }, String(w.errorCount)), + el('td', { class: 'num', 'data-label': 'Processing time' }, formatDuration(w.processingTime)), + el('td', { class: 'num', 'data-label': 'Max time' }, formatDuration(w.maxTime))))))))); } function kpi(label, value) { diff --git a/modules/manager2/webapp/js/pages/configuration.js b/modules/manager2/webapp/js/pages/configuration.js index 37852978bb2a..d18bfc3384a6 100644 --- a/modules/manager2/webapp/js/pages/configuration.js +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -16,7 +16,7 @@ */ import { api } from '../api.js'; -import { el, clear, toast, modal, confirm, stateBadge } from '../ui.js'; +import { el, clear, toast, modal, confirm, stateBadge, actionMenu } from '../ui.js'; const NUMERIC_TYPES = new Set(['int', 'long', 'short', 'byte', 'float', 'double']); const RISKY_ATTRIBUTES = new Set(['name', 'path', 'defaultHost']); @@ -163,7 +163,7 @@ export async function configuration(container) { el('div', { class: 'page-head' }, el('h1', {}, 'Configuration'), el('p', {}, 'The live component tree of this server. Changes apply immediately; save to make them permanent.'), - el('span', { style: 'flex:1' }), + el('span', { class: 'head-spacer' }), el('button', { type: 'button', class: 'btn', onclick: reloadAll }, 'Reload'), el('button', { type: 'button', class: 'btn btn-primary', onclick: () => saveToServerXml() }, 'Save to server.xml'))); view.append(el('div', { class: 'config-split' }, @@ -201,7 +201,9 @@ export async function configuration(container) { const hasKids = kids.length > 0; const isOpen = expanded.has(node.id); - const row = el('div', { class: 'config-node', style: 'padding-left:' + (depth * 16 + 4) + 'px' }, + // The indent is capped so that deeply nested branches still fit on + // narrow screens. + const row = el('div', { class: 'config-node', style: 'padding-left:' + (Math.min(depth, 6) * 16 + 4) + 'px' }, el('button', { type: 'button', class: 'config-node-toggle' + (hasKids ? '' : ' leaf'), 'aria-label': hasKids ? 'Toggle' : '', @@ -248,26 +250,35 @@ export async function configuration(container) { } selectedDetail = data; renderDetail(); + // On narrow screens the detail card is stacked below the tree; bring it + // into view after a selection so the result is immediately visible. + if (window.matchMedia('(max-width: 768px)').matches) { + detailCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } } function renderDetail() { const d = selectedDetail; clear(detailCard); + const actions = []; + if (addable(d)) { + actions.push({ label: '+ Add', onclick: () => addChildModal(d) }); + } + if (d.type !== 'server') { + actions.push({ + label: 'Remove', class: 'btn-danger', disabled: d.self, + title: d.self ? 'Cannot remove the component the manager is installed in' : 'Remove', + onclick: () => removeNode(d), + }); + } + actions.push(...lifecycleActions(d)); const head = el('div', { class: 'card-title-row' }, el('div', { class: 'config-detail-title' }, el('span', { class: 'config-type ' + d.type }, d.type), el('h3', {}, d.name || '(unnamed)'), d.state ? stateBadge(d.state) : ''), - el('div', { class: 'row-actions' }, - addable(d) ? el('button', { type: 'button', class: 'btn btn-sm', onclick: () => addChildModal(d) }, '+ Add') : null, - d.type !== 'server' ? el('button', { - type: 'button', class: 'btn btn-sm btn-danger', - disabled: d.self, - title: d.self ? 'Cannot remove the component the manager is installed in' : 'Remove', - onclick: () => removeNode(d), - }, 'Remove') : null, - ...lifecycleButtons(d))); + actionMenu(actions)); detailCard.append(head); if (d.className) { @@ -932,39 +943,40 @@ export async function configuration(container) { return d.state === 'STARTED' || d.state === 'AVAILABLE'; } - // The Start / Stop / Restart buttons of the detail card, for the - // components that implement Lifecycle (the node detail reports this - // as `lifecycle`). Not every change takes effect until the affected - // component is restarted, so the buttons make the restart explicit. + // The Start / Stop / Restart actions of the detail card, for the + // components that implement Lifecycle (the node detail reports this as + // `lifecycle`). Not every change takes effect until the affected + // component is restarted, so the actions make the restart explicit. // Start and Stop are disabled for the components that affect access // to this page (`affectsSelf`): stopping them would destroy the admin // session mid-request. Restart stays enabled for them: the client's // connection may be interrupted during the operation, but the // component is running again at the end and the client reconnects. - function lifecycleButtons(d) { + // Returned as action descriptors (see ui.js actionMenu): they render + // inline on wide screens and inside the overflow menu on narrow ones. + function lifecycleActions(d) { if (!d.lifecycle) return []; const running = isRunning(d); const selfImpact = d.affectsSelf; const selfImpactTitle = 'This component serves this page: starting or stopping it would interrupt access to the manager. Use Restart instead.'; return [ - el('span', { class: 'row-actions-sep', role: 'presentation' }), - el('button', { - type: 'button', class: 'btn btn-sm', + { + label: 'Start', disabled: running || selfImpact, title: selfImpact ? selfImpactTitle : 'Start', onclick: () => lifecycleOp(d, 'start'), - }, 'Start'), - el('button', { - type: 'button', class: 'btn btn-sm', + }, + { + label: 'Stop', disabled: !running || selfImpact, title: selfImpact ? selfImpactTitle : 'Stop', onclick: () => lifecycleOp(d, 'stop'), - }, 'Stop'), - el('button', { - type: 'button', class: 'btn btn-sm', + }, + { + label: 'Restart', title: 'Stop the component and start it again', onclick: () => lifecycleOp(d, 'restart'), - }, 'Restart'), + }, ]; } diff --git a/modules/manager2/webapp/js/pages/diagnostics.js b/modules/manager2/webapp/js/pages/diagnostics.js index d4cc82d926cd..b215d9f7756c 100644 --- a/modules/manager2/webapp/js/pages/diagnostics.js +++ b/modules/manager2/webapp/js/pages/diagnostics.js @@ -57,7 +57,7 @@ export async function diagnostics(container) { const pane = paneNodes[0]; const reloadRow = el('div', { class: 'row-actions', style: 'margin-bottom:14px;' }, el('input', { - type: 'text', placeholder: 'TLS SNI host name (optional)', style: 'width:280px;', id: 'tls-host', + type: 'text', placeholder: 'TLS SNI host name (optional)', class: 'tls-host', id: 'tls-host', }), el('button', { type: 'button', class: 'btn btn-sm', @@ -165,7 +165,7 @@ export async function diagnostics(container) { const pane = paneNodes[2]; pane.append(el('div', { class: 'card' }, el('div', { class: 'row-actions', style: 'margin-bottom:14px;' }, - el('select', { id: 'res-type', style: 'width:220px;' }, + el('select', { id: 'res-type', class: 'res-type' }, el('option', { value: '' }, 'All types'), el('option', { value: 'env/java:comp/env' }, 'env/java:comp/env'), el('option', { value: 'env/ejb' }, 'env/ejb'), diff --git a/modules/manager2/webapp/js/pages/hosts.js b/modules/manager2/webapp/js/pages/hosts.js index 57abf70715cd..35437eada12f 100644 --- a/modules/manager2/webapp/js/pages/hosts.js +++ b/modules/manager2/webapp/js/pages/hosts.js @@ -16,14 +16,14 @@ */ import { api } from '../api.js'; -import { el, clear, table, toast, modal, confirm } from '../ui.js'; +import { el, clear, table, toast, modal, confirm, actionMenu } from '../ui.js'; export async function hosts(container) { const view = el('div', {}, el('div', { class: 'page-head' }, el('h1', {}, 'Virtual hosts'), el('p', {}, 'Add, start, stop and remove virtual hosts on this engine.'), - el('span', { style: 'flex:1' }), + el('span', { class: 'head-spacer' }), el('button', { type: 'button', class: 'btn', onclick: () => persist() }, 'Save to server.xml'), el('button', { type: 'button', class: 'btn btn-primary', onclick: () => addHostModal() }, 'Add host'))); container.append(view); @@ -53,27 +53,29 @@ export async function hosts(container) { render: (h) => el('span', { class: 'badge ' + (h.started ? 'ok' : 'stop') }, h.started ? 'Running' : 'Stopped'), }, - { key: 'self', label: '', render: (h) => h.self ? el('span', { class: 'badge plain' }, 'this host') : '' }, + { + key: 'self', label: 'Self', + render: (h) => h.self + ? el('span', { class: 'badge plain' }, 'this host') + : el('span', { class: 'muted' }, '-'), + }, { key: 'actions', label: 'Actions', - render: (h) => el('div', { class: 'row-actions' }, + render: (h) => actionMenu([ h.started - ? el('button', { - type: 'button', class: 'btn btn-sm', disabled: h.self, - onclick: () => startStop(h, 'stop'), - }, 'Stop') - : el('button', { - type: 'button', class: 'btn btn-sm btn-primary', disabled: h.self, - onclick: () => startStop(h, 'start'), - }, 'Start'), - el('button', { - type: 'button', class: 'btn btn-sm btn-danger', disabled: h.self, + ? { label: 'Stop', disabled: h.self, onclick: () => startStop(h, 'stop') } + : { label: 'Start', class: 'btn-primary', disabled: h.self, onclick: () => startStop(h, 'start') }, + { + label: 'Remove', class: 'btn-danger', disabled: h.self, title: h.self ? 'Cannot remove the host the manager is installed in' : 'Remove', onclick: () => removeHost(h), - }, 'Remove')), - }], + }, + ]), + }, + ], rows: data, empty: 'No virtual hosts configured', + stackable: true, })); } diff --git a/modules/manager2/webapp/js/pages/monitoring.js b/modules/manager2/webapp/js/pages/monitoring.js index e0874da19977..203a009f7f3a 100644 --- a/modules/manager2/webapp/js/pages/monitoring.js +++ b/modules/manager2/webapp/js/pages/monitoring.js @@ -39,7 +39,7 @@ export async function monitoring(container) { const connectorsCard = el('div', { class: 'card' }, el('div', { class: 'card-title-row' }, el('h3', {}, el('span', { class: 'live-dot' }), 'Connectors')), - el('div', { class: 'table-wrap' })); + el('div', { class: 'table-wrap stackable' })); const connectorsWrap = connectorsCard.querySelector('.table-wrap'); view.append(connectorsCard); @@ -76,16 +76,16 @@ export async function monitoring(container) { function renderConnectors(connectors) { clear(connectorsWrap); const rows = connectors.map((c) => el('tr', {}, - el('td', {}, el('strong', {}, c.name)), - el('td', { class: 'num' }, c.threads.busy + ' / ' + c.threads.current + ' / ' + c.threads.max), - el('td', { class: 'num' }, String(c.threads.keepAlive)), + el('td', { 'data-label': 'Connector' }, el('strong', {}, c.name)), + el('td', { class: 'num', 'data-label': 'Threads' }, c.threads.busy + ' / ' + c.threads.current + ' / ' + c.threads.max), + el('td', { class: 'num', 'data-label': 'Keep-alive' }, String(c.threads.keepAlive)), c.requests - ? el('td', { class: 'num' }, formatMs(c.requests.processingTime) + ' (max ' + formatMs(c.requests.maxTime) + ')') - : el('td', {}, '-'), - el('td', { class: 'num' }, String(c.requests ? c.requests.count : '-')), - el('td', { class: 'num' }, String(c.requests ? c.requests.errors : '-')), - el('td', { class: 'num' }, c.requests ? formatBytes(c.requests.bytesReceived) : '-'), - el('td', { class: 'num' }, c.requests ? formatBytes(c.requests.bytesSent) : '-'))); + ? el('td', { class: 'num', 'data-label': 'Processing time' }, formatMs(c.requests.processingTime) + ' (max ' + formatMs(c.requests.maxTime) + ')') + : el('td', { 'data-label': 'Processing time' }, '-'), + el('td', { class: 'num', 'data-label': 'Requests' }, String(c.requests ? c.requests.count : '-')), + el('td', { class: 'num', 'data-label': 'Errors' }, String(c.requests ? c.requests.errors : '-')), + el('td', { class: 'num', 'data-label': 'Bytes in' }, c.requests ? formatBytes(c.requests.bytesReceived) : '-'), + el('td', { class: 'num', 'data-label': 'Bytes out' }, c.requests ? formatBytes(c.requests.bytesSent) : '-'))); connectorsWrap.append(el('table', { class: 'data' }, el('thead', {}, el('tr', {}, el('th', {}, 'Connector'), @@ -116,7 +116,7 @@ export async function monitoring(container) { el('td', {}, el('code', {}, (w.remoteAddrForwarded ? w.remoteAddrForwarded + ' (' + w.remoteAddr + ')' : (w.remoteAddr || '-')))), el('td', {}, w.virtualHost || '-'), - el('td', {}, + el('td', { class: 'wide' }, (w.method) ? el('code', {}, w.method + ' ' + w.uri + (w.queryString ? '?' + w.queryString : '') + ' ' + w.protocol) : '-'))); diff --git a/modules/manager2/webapp/js/pages/users.js b/modules/manager2/webapp/js/pages/users.js index 5e85b172201c..3d58a8eff4f0 100644 --- a/modules/manager2/webapp/js/pages/users.js +++ b/modules/manager2/webapp/js/pages/users.js @@ -16,7 +16,7 @@ */ import { api, get } from '../api.js'; -import { el, clear, toast, table, modal, confirm, icon } from '../ui.js'; +import { el, clear, toast, table, modal, confirm, icon, actionMenu } from '../ui.js'; export async function users(container) { let data = null; @@ -133,17 +133,17 @@ export async function users(container) { { key: 'roles', label: 'Roles', render: (u) => rolesBadges(u.roles, u.effectiveRoles) }, { key: 'groups', label: 'Groups', render: (u) => nameBadges(u.groups) }, { - key: 'actions', label: '', render: (u) => el('div', { class: 'row-actions' }, - el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); rolesModal('user', u); } }, 'Roles'), - el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); passwordModal(u); } }, 'Password'), - el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); removeUser(u); } }, 'Remove')), + key: 'actions', label: 'Actions', + render: (u) => actionMenu([ + { label: 'Roles', disabled: !canEdit(), onclick: () => rolesModal('user', u) }, + { label: 'Password', disabled: !canEdit(), onclick: () => passwordModal(u) }, + { label: 'Remove', class: 'btn-danger', disabled: !canEdit(), onclick: () => removeUser(u) }, + ]), }, ], rows: data.users || [], empty: 'No users in this database.', + stackable: true, }); const card = el('div', { class: 'card' }, @@ -166,17 +166,17 @@ export async function users(container) { { key: 'roles', label: 'Roles', render: (g) => rolesBadges(g.roles, null) }, { key: 'members', label: 'Members', render: (g) => nameBadges(g.members) }, { - key: 'actions', label: '', render: (g) => el('div', { class: 'row-actions' }, - el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); membersModal(g); } }, 'Members'), - el('button', { type: 'button', class: 'btn btn-sm', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); rolesModal('group', g); } }, 'Roles'), - el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, - onclick: (e) => { e.stopPropagation(); removeGroup(g); } }, 'Remove')), + key: 'actions', label: 'Actions', + render: (g) => actionMenu([ + { label: 'Members', disabled: !canEdit(), onclick: () => membersModal(g) }, + { label: 'Roles', disabled: !canEdit(), onclick: () => rolesModal('group', g) }, + { label: 'Remove', class: 'btn-danger', disabled: !canEdit(), onclick: () => removeGroup(g) }, + ]), }, ], rows: data.groups || [], empty: 'No groups in this database.', + stackable: true, }); return el('div', { class: 'card' }, @@ -205,13 +205,14 @@ export async function users(container) { .filter((g) => (g.roles || []).includes(r.rolename)) .map((g) => g.groupname)) }, { - key: 'actions', label: '', render: (r) => el('div', { class: 'row-actions' }, + key: 'actions', label: 'Actions', render: (r) => el('div', { class: 'row-actions' }, el('button', { type: 'button', class: 'btn btn-sm btn-danger', disabled: canEdit() ? null : true, onclick: (e) => { e.stopPropagation(); removeRole(r); } }, 'Remove')), }, ], rows: data.roles || [], empty: 'No roles defined in this database.', + stackable: true, }); return el('div', { class: 'card' }, diff --git a/modules/manager2/webapp/js/ui.js b/modules/manager2/webapp/js/ui.js index b7b23b26765e..67103ea337b2 100644 --- a/modules/manager2/webapp/js/ui.js +++ b/modules/manager2/webapp/js/ui.js @@ -270,17 +270,122 @@ export function drawer({ title, content, onClose = null }) { return close; } +// ============================ Menu ===================================== + +/** + * Show a small dropdown menu anchored to a trigger element (e.g. the kebab + * button of a row action list). Returns a close function. + * + * @param {object} opts { trigger (HTMLElement), items: [{label, variant + * ('danger'|'primary'), disabled, title, onClick}] } + */ +export function menu({ trigger, items }) { + const node = el('div', { class: 'menu', role: 'menu' }, + items.map((it) => el('button', { + type: 'button', + role: 'menuitem', + class: 'menu-item' + (it.variant ? ' ' + it.variant : ''), + disabled: it.disabled || null, + title: it.title || null, + onclick: () => { + close(); + if (it.onClick) it.onClick(); + }, + }, it.label))); + + const backdrop = el('div', { class: 'menu-backdrop' }); + document.body.append(backdrop, node); + + // Position below the trigger, right aligned; flip above when there is no + // room and clamp to the viewport. + const rect = trigger.getBoundingClientRect(); + const left = Math.max(8, Math.min(rect.right - node.offsetWidth, + window.innerWidth - node.offsetWidth - 8)); + let top = rect.bottom + 4; + if (top + node.offsetHeight > window.innerHeight - 8) { + top = Math.max(8, rect.top - 4 - node.offsetHeight); + } + node.style.cssText = 'left:' + left + 'px;top:' + top + 'px;'; + + let closed = false; + function close() { + if (closed) return; + closed = true; + backdrop.remove(); + node.remove(); + document.removeEventListener('keydown', onKey, true); + trigger.focus({ preventScroll: true }); + } + const onKey = (e) => { + if (e.key === 'Escape') close(); + }; + document.addEventListener('keydown', onKey, true); + backdrop.addEventListener('click', close); + const first = node.querySelector('.menu-item:not(:disabled)'); + if (first) first.focus(); + return close; +} + +/** + * Build a row action list that collapses into a kebab (overflow) menu on + * narrow screens: the buttons always render, CSS hides all of them except + * the kebab below 768 px, and the kebab opens the same actions in a + * dropdown menu. + * + * @param {Array<{label, class, onclick, disabled, title}>} actions + */ +export function actionMenu(actions) { + const wrap = el('div', { class: 'row-actions has-kebab' }); + for (const a of actions) { + wrap.append(el('button', { + type: 'button', + class: 'btn btn-sm' + (a.class ? ' ' + a.class : ''), + disabled: a.disabled || null, + title: a.title || null, + onclick: (e) => { + e.stopPropagation(); + if (a.onclick) a.onclick(); + }, + }, a.label)); + } + const kebab = el('button', { + type: 'button', + class: 'row-actions-kebab', + 'aria-label': 'More actions', + 'aria-haspopup': 'menu', + onclick: (e) => { + e.stopPropagation(); + menu({ + trigger: kebab, + items: actions.map((a) => ({ + label: a.label, + variant: a.class === 'btn-danger' ? 'danger' + : (a.class === 'btn-primary' ? 'primary' : ''), + disabled: a.disabled, + title: a.title, + onClick: a.onclick, + })), + }); + }, + }, icon('more', 16)); + wrap.append(kebab); + return wrap; +} + // ============================ Table ==================================== /** * Build a data table. * - * @param {object} opts { columns: [{key, label, sortable, render, numeric}], + * @param {object} opts { columns: [{key, label, sortable, render, numeric, + * wide (long text column that may wrap on narrow screens)}], * rows: [object], sortKey, sortAsc, onSort(key), onRowClick(row), - * empty (string) } + * empty (string), stackable (boolean - render as stacked cards on + * narrow screens; each cell carries its column label) } */ export function table(opts) { - const { columns, rows, sortKey = null, sortAsc = true, onSort, onRowClick, empty = 'No data' } = opts; + const { columns, rows, sortKey = null, sortAsc = true, onSort, onRowClick, + empty = 'No data', stackable = false } = opts; const thead = el('tr', {}, columns.map((c) => { const label = c.sortable @@ -295,12 +400,19 @@ export function table(opts) { const tbody = el('tbody', {}, rows.length === 0 ? el('tr', {}, el('td', { colspan: columns.length, class: 'empty' }, empty)) - : rows.map((row) => el('tr', { - onclick: onRowClick ? () => onRowClick(row) : null, - style: onRowClick ? 'cursor:pointer' : null, - }, columns.map((c) => { - const value = c.render ? c.render(row) : row[c.key]; - const node = el('td', { class: c.numeric ? 'num' : (c.muted ? 'muted' : '') }); + : rows.map((row) => el('tr', { + onclick: onRowClick ? () => onRowClick(row) : null, + style: onRowClick ? 'cursor:pointer' : null, + }, columns.map((c) => { + const value = c.render ? c.render(row) : row[c.key]; + const classes = []; + if (c.numeric) classes.push('num'); + else if (c.muted) classes.push('muted'); + if (c.wide) classes.push('wide'); + const node = el('td', { + class: classes.length > 0 ? classes.join(' ') : null, + 'data-label': stackable && c.label ? c.label : null, + }); if (value === null || value === undefined) { node.append(document.createTextNode('-')); } else if (value.nodeType) { @@ -311,7 +423,7 @@ export function table(opts) { return node; })))); - return el('div', { class: 'table-wrap' }, + return el('div', { class: 'table-wrap' + (stackable ? ' stackable' : '') }, el('table', { class: 'data' }, el('thead', {}, thead), tbody)); } @@ -337,6 +449,7 @@ const ICONS = { users: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z', close: 'M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z', config: 'M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h3v3h3v-3h3v3h-3v3h3v3h-3v-3h-3v3h-3v-3h3v-3h-3Z', + more: 'M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z', }; export function icon(name, size = 18) { From 4f93c52ef7b4f5cf09a088ea00521031feebc83c Mon Sep 17 00:00:00 2001 From: remm Date: Wed, 16 Sep 2026 19:55:09 +0200 Subject: [PATCH 08/17] Add download for logs Also redo the icons and the bar scaling. Add some accent colors. --- modules/manager2/manager2-design.md | 35 +++++-- .../tomcat/manager2/LogsApiServlet.java | 96 ++++++++++++++----- .../tomcat/manager2/TestManager2Webapp.java | 25 +++++ modules/manager2/webapp/WEB-INF/web.xml | 10 ++ modules/manager2/webapp/css/manager2.css | 69 +++++++++++-- modules/manager2/webapp/js/logviewer.js | 34 ++++++- modules/manager2/webapp/js/main.js | 17 ++-- modules/manager2/webapp/js/ui.js | 16 +++- 8 files changed, 241 insertions(+), 61 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 168313fbbeb0..f7b2e41da533 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -204,8 +204,10 @@ machine-readable `error` code and non-2xx status. | GET | `/api/diagnostics/threaddump` | manager-gui | thread dump text | | GET | `/api/logs` | manager-gui | server log files (JULI): name, size, modified, detected format (text/JSON) | | GET | `/api/logs/file?name=&lines=&level=&search=` | manager-gui | parsed + filtered tail of one server log file: the most recent `lines` (default 500) matching records, ordered most recent first (time, level, thread, source, message, throwable), level counts | +| GET | `/api/logs/download?name=` | manager-gui | the full, unfiltered raw server log file (`Content-Disposition: attachment`) | | GET | `/api/access-log` | manager-gui | access log files: name, size, modified, format, configured pattern, available fields | | GET | `/api/access-log/file?name=&lines=&method=&status=&user=&session=&search=` | manager-gui | parsed + filtered tail of one access log file: records, status-class counts, method counts | + | GET | `/api/access-log/download?name=` | manager-gui | the full, unfiltered raw access log file (`Content-Disposition: attachment`) | | GET | `/api/users?name=` | manager-gui | configured `UserDatabase` JNDI resources (name, id, type, readonly, writable) plus users (username, fullName, hasPassword, roles, groups, effectiveRoles), groups (groupname, description, roles, members) and roles of the selected database | | POST | `/api/users` | manager-gui | `{"username", "password", "fullName"?, "roles"?, "name"?}` create a user (409 when it exists); new roles are created on the fly | | DELETE | `/api/users/{username}?name=` | manager-gui | remove a user (404 when absent; 400 `SELF_REMOVAL` for the signed-in account) | @@ -270,7 +272,15 @@ server-side timestamp, so restarts and clock skew are handled. theme toggle. - Left navigation (collapses to bottom tab bar under 768 px): Dashboard, Applications, Hosts, Configuration, Users, Monitoring, Diagnostics, - Logs, Access log. + Logs, Access log. Each item has a single-path 24×24 icon (holes — server + LEDs/slots, the gear bore, beetle seam/spots, file text lines — filled + with `fill-rule: evenodd`); the active item takes a per-tab accent hue + (dashboard blue, apps violet, hosts teal, configuration slate, users + pink, monitoring green, diagnostics amber, logs cyan, access log indigo, + with lighter values in the dark theme) on icon, label and soft + background. The bottom tab bar shows icons only (the label is kept as + `aria-label`) because nine text labels do not fit legibly in a + 360–430 px viewport. - History-API routing (deep links work), one `index.html`, no full page reloads. `login.html` is the FORM-login page (see §7); it POSTs to `j_security_check` and redirects back to the original URL. @@ -571,7 +581,8 @@ server-side timestamp, so restarts and clock skew are handled. **Logs** - File selector over the JULI server log files (`catalina`, `localhost`, `manager`, `host-manager`, `catalina.out`), max-lines selector (500-5000), - refresh button. + refresh and download buttons (the download saves the full, unfiltered raw + file of the current selection). - Filters: severity (the levels actually present in the file) and free-text search. - Table: time, level (coloured badge), thread, source, message. Row click @@ -581,8 +592,9 @@ server-side timestamp, so restarts and clock skew are handled. file from the first line. **Access log** -- File selector over the access log files, max-lines selector, refresh - button. +- File selector over the access log files, max-lines selector, refresh and + download buttons (the download saves the full, unfiltered raw file of the + current selection). - Filters that are shown depend on the fields the configured format provides: method, status (class `1xx`-`5xx` or exact code), user, session ID and free-text search. A filter is only offered when its field is part @@ -606,7 +618,9 @@ server-side timestamp, so restarts and clock skew are handled. `localStorage`). - System font stack; 12-column grid; 8 px spacing scale; single accent colour; states (success/warning/danger) with colour + icon (never colour - alone). + alone). The accent is otherwise neutral: per-tab hues are reserved for + the active navigation item (see §5.1) so the current section is + recognisable at a glance, and are never used for content. - Responsive, below 768 px (and on landscape phones of any width up to 932 px, via a `max-height` media): management tables (applications, hosts, users, sessions, connectors, servlets, …) convert to stacked @@ -881,7 +895,7 @@ modules/manager2/ HostsApiServlet.java extends HostManagerServlet StatusApiServlet.java status endpoints StatusSnapshot.java MBean collection → JSON model - LogsApiServlet.java /api/logs + /api/access-log (list, tail, filters) + LogsApiServlet.java /api/logs + /api/access-log (list, tail, filters, raw download) LogParser.java JULI text/JSON log lines, access log pattern→regex AccessLogSupport.java access log field names, normalization UsersApiServlet.java /api/users + /api/groups + /api/roles @@ -967,7 +981,9 @@ modelled on the existing `TestManagerWebapp` but exercising the new flows: `USER_DATABASE_READONLY`; `manager-status` gets 403 on the users API. 9. **Logs**: list (JULI + access log files, detected format), tail with level / method / status / user / session / free-text filters (text and - JSON formats, pattern driven access log fields), and the returned + JSON formats, pattern driven access log fields), the raw download + (`/api/logs/download`, `/api/access-log/download`) returns the full + unfiltered file and rejects path traversal, and the returned records are asserted to be the most recent matching lines ordered from most recent to least recent. `manager-status` gets 403. 10. **Deploy wizard (browser E2E)**: the modal opens with only the @@ -1148,8 +1164,9 @@ disabled); high-volume tables (logs, access log, workers) scroll horizontally with the first column pinned and long values wrapping, not squeezed; tabs scroll when crowded; page-head, log and diagnostics controls go full width; modals show stacked full-width buttons; toasts -appear above the bottom nav; the bottom nav keeps all nine items with -labels truncated inside their slot; inputs are 16 px at ≤480 px (no iOS +appear above the bottom nav; the bottom nav keeps all nine items as icons +only (labels as `aria-label`, active item in its per-tab hue); inputs are +16 px at ≤480 px (no iOS focus zoom); the Configuration detail scrolls into view after a tree selection; a multi-segment deep link (e.g. an application detail) reloads to the correct page (the base-element fix above). diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java index 87b0c8cf884f..b3fbb6613c7e 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java @@ -21,6 +21,7 @@ import java.io.RandomAccessFile; import java.io.Serial; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -46,9 +47,9 @@ /** - * The Manager2 log API. Serves the list of server log files (JULI) and of access log files, and a filtered tail of one - * file. Only the most recent part of a file is read (everything older is discarded), and the records of a response are - * ordered from most recent to least recent. + * The Manager2 log API. Serves the list of server log files (JULI) and of access log files, a filtered tail of one + * file, and the full raw file for download. Only the most recent part of a file is read for the tail (everything older + * is discarded), and the records of a response are ordered from most recent to least recent. *

* Both log families are handled in their plain text and their JSON format (the JSON format is used when a log * handler or the access log valve is configured with the JSON formatter/valve). For the access log the pattern based @@ -162,10 +163,14 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) list(response, false); } else if ("/api/logs/file".equals(path)) { read(response, false, request); + } else if ("/api/logs/download".equals(path)) { + download(response, request); } else if ("/api/access-log".equals(path)) { list(response, true); } else if ("/api/access-log/file".equals(path)) { read(response, true, request); + } else if ("/api/access-log/download".equals(path)) { + download(response, request); } else { Api.notFound(response); } @@ -231,27 +236,8 @@ private void list(HttpServletResponse response, boolean access) throws IOExcepti private void read(HttpServletResponse response, boolean access, HttpServletRequest request) throws IOException { - File dir = logsDirectory(); - if (dir == null) { - Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", - sm.getString("manager2.logsDirMissing")); - return; - } - - String name = request.getParameter("name"); - if (name == null || !SAFE_NAME.matcher(name).matches()) { - Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.invalidLogName")); - return; - } - File file = new File(dir, name); - try { - if (!file.getCanonicalFile().toPath().startsWith(dir.getCanonicalFile().toPath()) || !file.isFile()) { - Api.notFound(response); - return; - } - } catch (IOException e) { - Api.notFound(response); + File file = resolveRequestFile(response, request); + if (file == null) { return; } @@ -285,6 +271,23 @@ private void read(HttpServletResponse response, boolean access, HttpServletReque } + /** + * Stream the full, unfiltered raw file so that it can be saved. + */ + private void download(HttpServletResponse response, HttpServletRequest request) throws IOException { + + File file = resolveRequestFile(response, request); + if (file == null) { + return; + } + response.setContentType("text/plain; charset=utf-8"); + response.setContentLengthLong(file.length()); + response.setHeader("Content-Disposition", "attachment; filename=\"" + file.getName() + "\""); + Files.copy(file.toPath(), response.getOutputStream()); + response.flushBuffer(); + } + + // --------------------------------------------------------- Server logs @@ -564,6 +567,51 @@ private static File logsDirectory() { } + /** + * Resolve the log file named by the {@code name} request parameter inside the logs directory. On failure the + * appropriate error response is already sent. + * + * @return the file or {@code null} when an error was sent + */ + private static File resolveRequestFile(HttpServletResponse response, HttpServletRequest request) + throws IOException { + File dir = logsDirectory(); + if (dir == null) { + Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", + sm.getString("manager2.logsDirMissing")); + return null; + } + String name = request.getParameter("name"); + if (name == null || !SAFE_NAME.matcher(name).matches()) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", + sm.getString("manager2.invalidLogName")); + return null; + } + File file = resolveLogFile(dir, name); + if (file == null) { + Api.notFound(response); + return null; + } + return file; + } + + + /** + * A file inside the given directory, or {@code null} when the name escapes the directory or does not exist. + */ + private static File resolveLogFile(File dir, String name) { + File file = new File(dir, name); + try { + if (!file.getCanonicalFile().toPath().startsWith(dir.getCanonicalFile().toPath()) || !file.isFile()) { + return null; + } + } catch (IOException e) { + return null; + } + return file; + } + + /** * Read the last {@link #READ_CAP} bytes of a file, starting on a line boundary. */ diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java index a57541bd0463..7636ef4d9072 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -658,6 +658,15 @@ public void testLogFilesListAndSeverityFilter() throws Exception { // Invalid file names are rejected. request(client, "GET", MANAGER2 + "/api/logs/file?name=..%2Fweb.xml", null, null, 400); + // The full raw file can be downloaded, unfiltered and without a line + // limit. + request(client, "GET", MANAGER2 + "/api/logs/download?name=catalina.2026-01-01.log", null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("Server starting")); + Assert.assertTrue(body.contains("Server done")); + // Invalid file names are rejected for downloads as well. + request(client, "GET", MANAGER2 + "/api/logs/download?name=..%2Fweb.xml", null, null, 400); + client.disconnect(); } @@ -741,6 +750,13 @@ public void testAccessLogTextPatternAndFilters() throws Exception { Assert.assertTrue(body.contains("\"matched\":1")); Assert.assertTrue(body.contains("/submit")); + // The full raw file can be downloaded. + request(client, "GET", MANAGER2 + "/api/access-log/download?name=localhost_access_log.2026-01-01.txt", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("GET /ok HTTP/1.1")); + Assert.assertTrue(body.contains("POST /submit HTTP/1.1")); + client.disconnect(); } @@ -791,6 +807,13 @@ public void testAccessLogJsonAndSessionFilter() throws Exception { Assert.assertTrue(body.contains("\"matched\":1")); Assert.assertTrue(body.contains("/forbidden")); + // The full raw file can be downloaded. + request(client, "GET", MANAGER2 + "/api/access-log/download?name=localhost_access_log.2026-01-02.txt", + null, null, 200); + body = client.getResponseBody(); + Assert.assertTrue(body.contains("AAAA-1111")); + Assert.assertTrue(body.contains("/forbidden")); + client.disconnect(); } @@ -807,6 +830,8 @@ public void testLogApiReadOnlyRoleDenied() throws Exception { // The log API is not part of the read-only status endpoints. request(client, "GET", MANAGER2 + "/api/logs", null, null, 403); request(client, "GET", MANAGER2 + "/api/access-log", null, null, 403); + request(client, "GET", MANAGER2 + "/api/logs/download?name=catalina.log", null, null, 403); + request(client, "GET", MANAGER2 + "/api/access-log/download?name=localhost_access_log.txt", null, null, 403); client.disconnect(); } diff --git a/modules/manager2/webapp/WEB-INF/web.xml b/modules/manager2/webapp/WEB-INF/web.xml index 6b7b41814f3e..854fa0ae7fc3 100644 --- a/modules/manager2/webapp/WEB-INF/web.xml +++ b/modules/manager2/webapp/WEB-INF/web.xml @@ -163,6 +163,11 @@ LogsApi /api/logs/file + + + LogsApi + /api/logs/download + LogsApi /api/access-log @@ -171,6 +176,11 @@ LogsApi /api/access-log/file + + + LogsApi + /api/access-log/download + UsersApi /api/users diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index 99bb3fd15504..10b5e1c2f857 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -72,6 +72,26 @@ --danger-soft: #fde8ea; --info: #2456a6; --info-soft: #e7eefb; + + /* Accent hue of each nav item for its active state (one per tab). */ + --nav-dashboard: #2456a6; + --nav-dashboard-soft: #e7edf9; + --nav-apps: #7c3aed; + --nav-apps-soft: #f1ebfd; + --nav-hosts: #0d9488; + --nav-hosts-soft: #e4f5f3; + --nav-config: #64748b; + --nav-config-soft: #eef1f5; + --nav-users: #db2777; + --nav-users-soft: #fce8f1; + --nav-monitoring: #16a34a; + --nav-monitoring-soft: #e6f6ec; + --nav-diagnostics: #d97706; + --nav-diagnostics-soft: #fdf2e2; + --nav-logs: #0891b2; + --nav-logs-soft: #e2f4f8; + --nav-access-log: #4f46e5; + --nav-access-log-soft: #ebeafd; } :root[data-theme="dark"] { @@ -102,6 +122,26 @@ --info: #6ea8ff; --info-soft: #16233d; + /* Accent hue of each nav item for its active state (one per tab). */ + --nav-dashboard: #6ea8ff; + --nav-dashboard-soft: #16233d; + --nav-apps: #a78bfa; + --nav-apps-soft: #2a2144; + --nav-hosts: #2dd4bf; + --nav-hosts-soft: #143532; + --nav-config: #94a3b8; + --nav-config-soft: #262d3a; + --nav-users: #f472b6; + --nav-users-soft: #442031; + --nav-monitoring: #4cc98a; + --nav-monitoring-soft: #16321f; + --nav-diagnostics: #e5b93c; + --nav-diagnostics-soft: #38300f; + --nav-logs: #38bdf8; + --nav-logs-soft: #12333c; + --nav-access-log: #818cf8; + --nav-access-log-soft: #222044; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.5); } @@ -220,7 +260,18 @@ button { font: inherit; color: inherit; cursor: pointer; } } .nav-item svg { fill: currentColor; flex: none; } .nav-item:hover { background: var(--bg-hover); color: var(--text); } -.nav-item.active { background: var(--accent-soft); color: var(--accent-text); font-weight: 600; } +/* The active tab takes the accent hue of its section (see the per-tab + --nav-* tokens); the hover state stays neutral. */ +.nav-item.active { font-weight: 600; } +.nav-item.active.nav-tab-dashboard { color: var(--nav-dashboard); background: var(--nav-dashboard-soft); } +.nav-item.active.nav-tab-apps { color: var(--nav-apps); background: var(--nav-apps-soft); } +.nav-item.active.nav-tab-hosts { color: var(--nav-hosts); background: var(--nav-hosts-soft); } +.nav-item.active.nav-tab-config { color: var(--nav-config); background: var(--nav-config-soft); } +.nav-item.active.nav-tab-users { color: var(--nav-users); background: var(--nav-users-soft); } +.nav-item.active.nav-tab-monitoring { color: var(--nav-monitoring); background: var(--nav-monitoring-soft); } +.nav-item.active.nav-tab-diagnostics { color: var(--nav-diagnostics); background: var(--nav-diagnostics-soft); } +.nav-item.active.nav-tab-logs { color: var(--nav-logs); background: var(--nav-logs-soft); } +.nav-item.active.nav-tab-access-log { color: var(--nav-access-log); background: var(--nav-access-log-soft); } .content { min-width: 0; @@ -323,6 +374,7 @@ table.data .muted { color: var(--text-faint); } .log-field { width: 220px; margin-bottom: 0; } .log-field-search { width: 260px; } .log-field-btn { width: auto; } +.log-btns { display: flex; gap: var(--space-2); } .log-filters { display: flex; gap: var(--space-4); flex-wrap: wrap; flex: 1 1 auto; } .log-filters .field { width: 160px; margin-bottom: 0; } .log-filters .log-field-search { width: 220px; } @@ -870,7 +922,8 @@ pre.block { height: var(--bottomnav-h); z-index: 30; } - .nav-item { flex-direction: column; gap: 3px; font-size: 10.5px; padding: 6px 10px; } + .nav-item { flex-direction: column; gap: 3px; font-size: 10.5px; padding: 6px 10px; justify-content: center; } + .nav-item svg { width: 20px; height: 20px; } .content { padding: var(--space-4) var(--space-4) calc(var(--bottomnav-h) + var(--space-5)); } .topbar-status { display: none; } .brand-version { display: none; } @@ -940,6 +993,7 @@ pre.block { .log-controls { flex-direction: column; align-items: stretch; gap: var(--space-3); } .log-controls .field { width: 100%; } .log-field-btn .btn { width: 100%; } + .log-btns { flex-direction: column; } .log-filters { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } .log-filters .log-field-search { grid-column: 1 / -1; } @@ -961,14 +1015,11 @@ pre.block { /* Toasts render above the bottom nav */ .toast-region { bottom: calc(var(--bottomnav-h) + var(--space-4)); } - /* Bottom nav: keep the labels inside their slot */ + /* Bottom nav: icons only. The labels are not readable at this width + (9 items in ~390 px) and the icons carry the meaning; the accessible + name is kept via the aria-label set in main.js. */ .nav-item { min-width: 0; } - .nav-item .nav-label { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + .nav-item .nav-label { display: none; } .icon-btn { min-width: 44px; min-height: 44px; } } diff --git a/modules/manager2/webapp/js/logviewer.js b/modules/manager2/webapp/js/logviewer.js index dcd6a70bf82e..ab724e8df582 100644 --- a/modules/manager2/webapp/js/logviewer.js +++ b/modules/manager2/webapp/js/logviewer.js @@ -20,7 +20,7 @@ // selected file, so that only what the configured log format actually // provides is filterable. -import { api } from './api.js'; +import { api, BASE } from './api.js'; import { el, clear, table, drawer, formatBytes, formatMs } from './ui.js'; // Column definitions per field name. `render` receives the row and returns a @@ -42,7 +42,6 @@ const COLUMNS = { localAddr: { label: 'Local addr' }, localServerName: { label: 'Server' }, user: { label: 'User' }, - logicalUserName: { label: 'User (identd)' }, path: { label: 'Path' }, query: { label: 'Query' }, request: { label: 'Request', wide: true }, @@ -115,6 +114,7 @@ function showRecord(row) { const body = el('div', {}); for (const [key, value] of Object.entries(row)) { if (value === null || value === undefined) continue; + if (key === 'logicalUserName') continue; if (key === 'throwable' || key === 'raw' || key === 'message') { body.append( el('h3', { style: 'margin:14px 0 8px;' }, labelFor(key)), @@ -214,6 +214,25 @@ export async function logPage(container, opts) { 'Refresh'); refreshBtn.addEventListener('click', () => { loadList(); }); + // Download the full, unfiltered raw file of the current selection. A + // throw-away anchor keeps the SPA in place: the server answers with + // Content-Disposition: attachment, so the browser saves the file instead + // of navigating. + const downloadBtn = el('button', { type: 'button', class: 'btn btn-sm' }, + 'Download'); + downloadBtn.disabled = true; + downloadBtn.addEventListener('click', () => { + if (!state.file) return; + const path = kind === 'log' ? '/api/logs/download' : '/api/access-log/download'; + const a = el('a', { + href: BASE + path + '?name=' + encodeURIComponent(state.file), + download: state.file, + }); + document.body.append(a); + a.click(); + a.remove(); + }); + const filterHolder = el('div', { class: 'log-filters' }); const fileField = el('div', { class: 'field log-field' }, @@ -221,7 +240,8 @@ export async function logPage(container, opts) { const linesField = el('div', { class: 'field log-field' }, el('label', {}, 'Max lines'), linesSelect); const refreshField = el('div', { class: 'field log-field log-field-btn' }, - el('label', {}, '\u00a0'), refreshBtn); + el('label', {}, '\u00a0'), + el('div', { class: 'log-btns' }, refreshBtn, downloadBtn)); fileSelect.addEventListener('change', () => { state.file = fileSelect.value; @@ -269,7 +289,7 @@ export async function logPage(container, opts) { }))), f.status || '', (v) => { state.filters.status = v; loadFile(); }))); } - if (has(fields, 'user') || has(fields, 'logicalUserName')) { + if (has(fields, 'user')) { filterHolder.append(el('div', { class: 'field log-field' }, el('label', {}, 'User'), textInput('Filter by user', (v) => { state.filters.user = v; loadFile(); }, f.user || ''))); @@ -302,6 +322,7 @@ export async function logPage(container, opts) { if (state.files.length === 0) { fileSelect.append(el('option', { value: '' }, 'No log files found')); fileSelect.disabled = true; + downloadBtn.disabled = true; clear(tableHolder); tableHolder.append(el('div', { class: 'empty' }, 'No ' + (kind === 'log' ? 'log' : 'access log') + ' files were found in the logs directory.')); @@ -318,6 +339,7 @@ export async function logPage(container, opts) { state.file = state.files[0].name; } fileSelect.value = state.file; + downloadBtn.disabled = !state.file; loadFile(); } @@ -344,7 +366,9 @@ export async function logPage(container, opts) { return; } - state.fields = data.fields || []; + // The identd field (%l, "User (identd)") is dead in practice: the value + // is always "-", so it is dropped from the columns and the user filter. + state.fields = (data.fields || []).filter((f) => f !== 'logicalUserName'); buildFilters(state.fields, data); const records = data.records || []; diff --git a/modules/manager2/webapp/js/main.js b/modules/manager2/webapp/js/main.js index 6980b6d8c464..042a4c927ae3 100644 --- a/modules/manager2/webapp/js/main.js +++ b/modules/manager2/webapp/js/main.js @@ -96,19 +96,16 @@ function buildNav() { for (const item of NAV_ITEMS) { const btn = nav.querySelector('[data-nav="' + item.route + '"]'); btn.innerHTML = ''; - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('width', '18'); - svg.setAttribute('height', '18'); - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', svgPath(item.icon)); - svg.append(path); - // The label is a span so that it can be truncated on narrow screens - // (see the bottom nav rules in the CSS). + // The per-tab class selects the accent hue of the active state (CSS); + // the aria-label keeps the accessible name when the visible label is + // hidden on narrow screens (bottom nav). + btn.classList.add('nav-tab-' + item.icon); + btn.setAttribute('aria-label', item.label); + btn.append(icon(item.icon, 18)); const label = document.createElement('span'); label.className = 'nav-label'; label.textContent = item.label; - btn.append(svg, label); + btn.append(label); btn.addEventListener('click', () => { window.history.pushState({}, '', BASE + item.route); render(); diff --git a/modules/manager2/webapp/js/ui.js b/modules/manager2/webapp/js/ui.js index 67103ea337b2..6fe24cc94a4f 100644 --- a/modules/manager2/webapp/js/ui.js +++ b/modules/manager2/webapp/js/ui.js @@ -432,9 +432,9 @@ export function table(opts) { const ICONS = { dashboard: 'M3 3h8v8H3V3Zm10 0h8v5h-8V3Zm0 7h8v11h-8V10ZM3 13h8v8H3v-8Z', apps: 'M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 3.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 0 0-7 0Z', - hosts: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 2c1.9 0 3.7.6 5.2 1.6-.3 1.9-1.1 3.6-2.3 4.9-1.9-.3-3.8-.3-5.6 0-1.3-1.3-2.1-3-2.4-4.9A9.9 9.9 0 0 1 12 4Zm-7.7 4.4c.7 2.7 2.1 5 4 6.7-.3 1.9-.9 3.6-1.8 5.1A8 8 0 0 1 4.3 8.4Zm7.7 12.1c-.7 0-1.4-.1-2.1-.2.9-1.6 1.6-3.4 1.9-5.3 1.3-.2 2.7-.2 4 0 .3 1.9 1 3.7 1.9 5.3-.7.1-1.4.2-2.1.2h-3.6Zm7.7-1.8c-.9-1.5-1.5-3.2-1.8-5.1 1.9-1.7 3.3-4 4-6.7a8 8 0 0 1-2.2 11.8Z', + hosts: 'M4.5 4h15A1.5 1.5 0 0 1 21 5.5v3.5A1.5 1.5 0 0 1 19.5 10.5h-15A1.5 1.5 0 0 1 3 9v-3.5A1.5 1.5 0 0 1 4.5 4ZM5.35 7.25a1.25 1.25 0 1 0 2.5 0a1.25 1.25 0 1 0 -2.5 0ZM11.05 6.7h5.4A0.55 0.55 0 0 1 17 7.25v0A0.55 0.55 0 0 1 16.45 7.8h-5.4A0.55 0.55 0 0 1 10.5 7.25v0A0.55 0.55 0 0 1 11.05 6.7ZM4.5 13.5h15A1.5 1.5 0 0 1 21 15v3.5A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5v-3.5A1.5 1.5 0 0 1 4.5 13.5ZM5.35 16.75a1.25 1.25 0 1 0 2.5 0a1.25 1.25 0 1 0 -2.5 0ZM11.05 16.2h5.4A0.55 0.55 0 0 1 17 16.75v0A0.55 0.55 0 0 1 16.45 17.3h-5.4A0.55 0.55 0 0 1 10.5 16.75v0A0.55 0.55 0 0 1 11.05 16.2Z', monitoring: 'M3 13h4l3-8 4 14 3-8h4v2h-2.5l-4.5 10-4-14-2 8H3v-2Z', - diagnostics: 'M10.5 2h3a1 1 0 0 1 1 .9l.2 2.1 2 .7 1.5-1.6a1 1 0 0 1 1.3-.2l2.1 1.5a1 1 0 0 1 .3 1.3l-1.6 1.6.7 2 2.1.2a1 1 0 0 1 .9 1v3a1 1 0 0 1-.9 1l-2.1.2-.7 2 1.6 1.6a1 1 0 0 1 .2 1.3l-1.5 2.1a1 1 0 0 1-1.3.2l-1.6-1.6-2 .7-.2 2.1a1 1 0 0 1-1 .9h-3a1 1 0 0 1-1-.9l-.2-2.1-2-.7-1.5 1.6a1 1 0 0 1-1.3.2l-2.1-1.5a1 1 0 0 1-.3-1.3l1.6-1.6-.7-2-2.1-.2a1 1 0 0 1-.9-1v-3a1 1 0 0 1 .9-1l2.1-.2.7-2-1.6-1.6a1 1 0 0 1-.2-1.3l1.5-2.1a1 1 0 0 1 1.3-.2l1.6 1.6 2-.7.2-2.1a1 1 0 0 1 1-.9Zm1.5 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z', + diagnostics: 'M9.3 5.9a2.7 2.7 0 1 0 5.4 0a2.7 2.7 0 1 0 -5.4 0ZM7.8 14.2a4.2 5.9 0 1 0 8.4 0a4.2 5.9 0 1 0 -8.4 0ZM12 9.8h0A0.28 0.28 0 0 1 12.28 10.08v8.24A0.28 0.28 0 0 1 12 18.6h0A0.28 0.28 0 0 1 11.72 18.32v-8.24A0.28 0.28 0 0 1 12 9.8ZM9.15 11.9a0.95 0.95 0 1 0 1.9 0a0.95 0.95 0 1 0 -1.9 0ZM12.95 15.9a0.95 0.95 0 1 0 1.9 0a0.95 0.95 0 1 0 -1.9 0ZM9.3 17a0.8 0.8 0 1 0 1.6 0a0.8 0.8 0 1 0 -1.6 0ZM11.07 3.56L8.37 1.26L7.63 2.14L10.33 4.44ZM13.67 4.44L16.37 2.14L15.63 1.26L12.93 3.56ZM8.78 10.34L4.98 8.44L4.42 9.56L8.22 11.46ZM15.78 11.46L19.58 9.56L19.02 8.44L15.22 10.34ZM8.1 13.58L4 13.58L4 14.83L8.1 14.83ZM15.9 14.83L20 14.83L20 13.58L15.9 13.58ZM8.2 16.95L4.4 19.05L5 20.15L8.8 18.05ZM15.2 18.05L19 20.15L19.6 19.05L15.8 16.95Z', sun: 'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm0-5h.01L13 4h-2l1-2Zm0 20h.01L13 20h-2l1 2ZM2 12l2 1v-2L2 12Zm20 0v.01L22 12l-2 1v-2l2 1ZM4.9 4.9 6.3 6.3 4.9 4.9Zm14.2 14.2-1.4-1.4 1.4 1.4ZM4.9 19.1l1.4-1.4-1.4 1.4ZM19.1 4.9l-1.4 1.4 1.4-1.4Z', moon: 'M12 3a9 9 0 1 0 9 9c0-.5 0-1-.1-1.4A5.4 5.4 0 0 1 12 3Z', logout: 'M16 13v-2H7V8l-5 4 5 4v-3h9Zm3-10H11a2 2 0 0 0-2 2v3h2V5h8v14h-8v-3H9v3a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Z', @@ -445,13 +445,18 @@ const ICONS = { trash: 'M6 7h12l-1 14H7L6 7Zm3-4h6l1 2h4v2H4V5h4l1-2Z', plus: 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z', logs: 'M4 4h16v2.5H4V4Zm0 4.75h16v2.5H4v-2.5ZM4 13.5h10v2.5H4v-2.5Zm0 4.75h16v2.5H4v-2.5Z', - 'access-log': 'M3 5h3.5v3H3V5Zm5.5 0H21v3H8.5V5ZM3 10.5h3.5v3H3v-3Zm5.5 0H21v3H8.5v-3ZM3 16h3.5v3H3v-3Zm5.5 0H21v3H8.5v-3Z', + 'access-log': 'M5.9 3h8.7A1.4 1.4 0 0 1 16 4.4v15.2A1.4 1.4 0 0 1 14.6 21h-8.7A1.4 1.4 0 0 1 4.5 19.6v-15.2A1.4 1.4 0 0 1 5.9 3ZM7.65 6.2h5.2A0.65 0.65 0 0 1 13.5 6.85v0A0.65 0.65 0 0 1 12.85 7.5h-5.2A0.65 0.65 0 0 1 7 6.85v0A0.65 0.65 0 0 1 7.65 6.2ZM7.65 9.6h5.2A0.65 0.65 0 0 1 13.5 10.25v0A0.65 0.65 0 0 1 12.85 10.9h-5.2A0.65 0.65 0 0 1 7 10.25v0A0.65 0.65 0 0 1 7.65 9.6ZM7.65 16.2h5.2A0.65 0.65 0 0 1 13.5 16.85v0A0.65 0.65 0 0 1 12.85 17.5h-5.2A0.65 0.65 0 0 1 7 16.85v0A0.65 0.65 0 0 1 7.65 16.2ZM16 13.6L19.6 13.6L19.6 11.6L16 11.6ZM18.6 10L22.5 12.6L18.6 15.2L18.6 12.6Z', users: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z', close: 'M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z', - config: 'M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h3v3h3v-3h3v3h-3v3h3v3h-3v-3h-3v3h-3v-3h3v-3h-3Z', + config: 'M10.5 2h3a1 1 0 0 1 1 .9l.2 2.1 2 .7 1.5-1.6a1 1 0 0 1 1.3-.2l2.1 1.5a1 1 0 0 1 .3 1.3l-1.6 1.6.7 2 2.1.2a1 1 0 0 1 .9 1v3a1 1 0 0 1-.9 1l-2.1.2-.7 2 1.6 1.6a1 1 0 0 1 .2 1.3l-1.5 2.1a1 1 0 0 1-1.3.2l-1.6-1.6-2 .7-.2 2.1a1 1 0 0 1-1 .9h-3a1 1 0 0 1-1-.9l-.2-2.1-2-.7-1.5 1.6a1 1 0 0 1-1.3.2l-2.1-1.5a1 1 0 0 1-.3-1.3l1.6-1.6-.7-2-2.1-.2a1 1 0 0 1-.9-1v-3a1 1 0 0 1 .9-1l2.1-.2.7-2-1.6-1.6a1 1 0 0 1-.2-1.3l1.5-2.1a1 1 0 0 1 1.3-.2l1.6 1.6 2-.7.2-2.1a1 1 0 0 1 1-.9Zm1.5 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z', more: 'M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z', }; +// Icons whose inner subpaths are holes (server LEDs and slots, the beetle +// wing seam and spots, the file text lines) and therefore need even-odd +// fill to render correctly. +const EVENODD_ICONS = new Set(['hosts', 'config', 'diagnostics', 'access-log']); + export function icon(name, size = 18) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); @@ -460,6 +465,9 @@ export function icon(name, size = 18) { svg.setAttribute('aria-hidden', 'true'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', ICONS[name] || ''); + if (EVENODD_ICONS.has(name)) { + path.setAttribute('fill-rule', 'evenodd'); + } svg.append(path); return svg; } From f08b3d3d7366a89ccfccb6fe5097edda6359f0dc Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 00:14:46 +0200 Subject: [PATCH 09/17] Update to use the new StoreConfig layout restoration --- .../java/org/apache/tomcat/manager2/ConfigApiServlet.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index cba5d26dacf9..09262d5bfef3 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -92,6 +92,7 @@ import org.apache.catalina.storeconfig.StoreDescription; import org.apache.catalina.storeconfig.StoreFileMover; import org.apache.catalina.storeconfig.StoreLoader; +import org.apache.catalina.storeconfig.XMLFormatPreserver; import org.apache.catalina.tribes.Channel; import org.apache.catalina.tribes.ChannelInterceptor; import org.apache.catalina.tribes.ChannelReceiver; @@ -5204,6 +5205,8 @@ private CapturingContextSF previewServerPreservingContexts(StoreConfig storeConf */ private void storeServer(StoreConfig storeConfig, PrintWriter writer, CapturingContextSF capturing) throws Exception { + StoreFileMover mover = + new StoreFileMover(Bootstrap.getCatalinaBase(), storeConfig.getServerFilename(), storeConfig.getRegistry().getEncoding()); StoreDescription desc = storeConfig.getRegistry().findDescription(StandardContext.class); boolean oldSeparate = desc.isStoreSeparate(); @@ -5221,7 +5224,10 @@ private void storeServer(StoreConfig storeConfig, PrintWriter writer, CapturingC } desc.setStoreFactory(capturing); } - storeConfig.store(writer, -2, server); + StringWriter buffer = new StringWriter(); + storeConfig.store(new PrintWriter(buffer), -2, server); + writer.write(XMLFormatPreserver.preserve(mover.getConfigOld(), buffer.toString(), + storeConfig.getRegistry().getEncoding())); } finally { desc.setStoreSeparate(oldSeparate); desc.setExternalAllowed(oldAllowed); From aa4a835e24752cc9fd52ef409745a48eec888aec Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 00:46:33 +0200 Subject: [PATCH 10/17] Fold side bar to save space Much needed for logs. --- modules/manager2/manager2-design.md | 17 +++++-- modules/manager2/webapp/css/manager2.css | 61 ++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index f7b2e41da533..2956392b10a6 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -278,9 +278,15 @@ server-side timestamp, so restarts and clock skew are handled. (dashboard blue, apps violet, hosts teal, configuration slate, users pink, monitoring green, diagnostics amber, logs cyan, access log indigo, with lighter values in the dark theme) on icon, label and soft - background. The bottom tab bar shows icons only (the label is kept as - `aria-label`) because nine text labels do not fit legibly in a - 360–430 px viewport. + background. The navigation is always an icons-only 60 px rail (the + labels are kept as `aria-label`): a 220 px rail of text is too costly at + every width, and nine labels are not legible at phone widths. In + landscape viewports, hovering the rail — or landing keyboard focus in + it — pops the sidenav out at full width (220 px) and label over the + content as a `position: fixed` flyout; the content is pinned to the + second grid column, so the grid and the view are never resized. Portrait + viewports keep the rail collapsed, and the bottom tab bar is icons-only + as well. - History-API routing (deep links work), one `index.html`, no full page reloads. `login.html` is the FORM-login page (see §7); it POSTs to `j_security_check` and redirects back to the original URL. @@ -634,7 +640,10 @@ server-side timestamp, so restarts and clock skew are handled. `table-layout:auto` any extra table width flows into the only wrapping column, keeping rows to one or two lines for information density. Charts re-flow to a single column; row actions move into an overflow - (kebab) menu; form fields and modals go full width. + (kebab) menu; form fields and modals go full width. +- The side navigation is an icons-only 60 px rail at all widths where it + is shown; in landscape viewports it pops out at full width and label on + hover/focus as a fixed flyout that never resizes the view (see §5.1). - Accessibility (WCAG 2.1 AA): semantic landmarks, visible focus states, keyboard-operable modals/drawers (focus trap, `Esc` closes), `aria-live="polite"` toasts, live chart updates announced at reduced diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index 10b5e1c2f857..4d76cc3afe4f 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -34,6 +34,7 @@ --topbar-h: 56px; --sidenav-w: 220px; + --sidenav-icon-w: 60px; --bottomnav-h: 56px; --shadow-1: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.10); @@ -232,9 +233,15 @@ button { font: inherit; color: inherit; cursor: pointer; } .icon-btn:hover { background: var(--bg-hover); border-color: var(--border); } .icon-btn svg { fill: currentColor; } +/* The sidenav is an icon rail at all widths where it is shown (the bottom + tab bar takes over at 768 px and below). In landscape the rail pops out + to full width and label on hover/focus (see the landscape media below); + in portrait it stays collapsed. The content is pinned to the second + grid column so the pop-out — position:fixed, out of the grid flow — + never resizes the view. */ .shell-main { display: grid; - grid-template-columns: var(--sidenav-w) 1fr; + grid-template-columns: var(--sidenav-icon-w) 1fr; min-height: 0; } @@ -242,15 +249,21 @@ button { font: inherit; color: inherit; cursor: pointer; } .sidenav { display: flex; flex-direction: column; gap: 2px; - padding: var(--space-4) var(--space-3); + padding: var(--space-3) var(--space-2); background: var(--bg-sidenav); border-right: 1px solid var(--border); overflow-y: auto; + /* Nine items do not always fit the rail's height; it may scroll, but + the scrollbar is hidden in the collapsed state (wheel, touch and + keyboard still scroll) and restored when the rail pops out. */ + scrollbar-width: none; } +.sidenav::-webkit-scrollbar { display: none; } .nav-item { - display: flex; align-items: center; gap: 10px; - padding: 9px 12px; + display: flex; align-items: center; justify-content: center; + gap: 0; + padding: 10px 0; background: transparent; border: none; border-radius: var(--radius-s); color: var(--text-soft); @@ -258,6 +271,7 @@ button { font: inherit; color: inherit; cursor: pointer; } text-align: left; transition: background var(--transition), color var(--transition); } +.nav-label { display: none; } .nav-item svg { fill: currentColor; flex: none; } .nav-item:hover { background: var(--bg-hover); color: var(--text); } /* The active tab takes the accent hue of its section (see the per-tab @@ -274,6 +288,7 @@ button { font: inherit; color: inherit; cursor: pointer; } .nav-item.active.nav-tab-access-log { color: var(--nav-access-log); background: var(--nav-access-log-soft); } .content { + grid-column: 2; min-width: 0; overflow-y: auto; padding: var(--space-5) var(--space-6) 60px; @@ -911,6 +926,8 @@ pre.block { @media (max-width: 768px) { .shell-main { grid-template-columns: 1fr; } + /* The sidenav is a bottom bar here; the content takes the only column. */ + .content { grid-column: 1; } .sidenav { position: fixed; bottom: 0; left: 0; right: 0; top: auto; @@ -1083,6 +1100,42 @@ pre.block { .table-wrap.log-table table.data td.wide { min-width: 100vw; max-width: none; } } +/* In landscape, hovering the icon rail — or landing keyboard focus in it — + pops the sidenav out at full width and label over the content. The + flyout is position:fixed (out of the grid flow) so the grid and the + view are never resized; leaving the rail collapses it again. Portrait + viewports keep the rail collapsed (no hover pop-out). */ +@media (orientation: landscape) and (min-width: 769px) { + .sidenav:hover, + .sidenav:focus-within { + position: fixed; + top: var(--topbar-h); + bottom: 0; + left: 0; + width: var(--sidenav-w); + z-index: 30; + padding: var(--space-4) var(--space-3); + border-radius: 0 var(--radius) var(--radius) 0; + box-shadow: var(--shadow-2); + scrollbar-width: auto; + } + .sidenav:hover::-webkit-scrollbar, + .sidenav:focus-within::-webkit-scrollbar { display: block; } + .sidenav:hover .nav-item, + .sidenav:focus-within .nav-item { + justify-content: flex-start; + gap: 10px; + padding: 9px 12px; + } + .sidenav:hover .nav-label, + .sidenav:focus-within .nav-label { + display: block; + animation: sidenav-label-in 120ms ease; + } +} + +@keyframes sidenav-label-in { from { opacity: 0; } } + @media (max-width: 480px) { .content { padding: var(--space-4) var(--space-3) calc(var(--bottomnav-h) + var(--space-4)); } .card { padding: var(--space-4); } From 8748e71f012aff3835f8f608b4180ab684ac3d35 Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 10:23:17 +0200 Subject: [PATCH 11/17] UI tweaks and content optimizations --- modules/manager2/manager2-design.md | 54 ++++++++++++------- modules/manager2/webapp/css/manager2.css | 40 ++++++++------ modules/manager2/webapp/js/logviewer.js | 4 ++ .../manager2/webapp/js/pages/configuration.js | 2 +- .../manager2/webapp/js/pages/diagnostics.js | 1 + .../manager2/webapp/js/pages/monitoring.js | 15 ++++-- modules/manager2/webapp/js/ui.js | 2 +- 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 2956392b10a6..1773649fa3b6 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -270,7 +270,9 @@ server-side timestamp, so restarts and clock skew are handled. used as `favicon.ico` and on the login page) + product name, server identity (version, host, uptime), global search, user menu (logout), theme toggle. -- Left navigation (collapses to bottom tab bar under 768 px): Dashboard, +- Left navigation (collapses to bottom tab bar under 900 px — slightly + above the old 768 px breakpoint so iPad portrait and small tablets get + the phone layout): Dashboard, Applications, Hosts, Configuration, Users, Monitoring, Diagnostics, Logs, Access log. Each item has a single-path 24×24 icon (holes — server LEDs/slots, the gear bore, beetle seam/spots, file text lines — filled @@ -573,7 +575,11 @@ server-side timestamp, so restarts and clock skew are handled. **Monitoring** - Live workers table (5 s cadence): stage, processing time, bytes sent/received, remote address (forwarded + actual), virtual host, - request line. Stage codes colour-coded; "P/R/K" rows dimmed. + request line. Only *active* workers are listed — stages P (parsing, + blue), S (service, green) and F (finishing, amber), each with its own + bullet colour; R (ready), K (keep-alive) and unknown stages are idle + one way or another and are left out so the table stays short; the + header badge reports "N active · X idle". - Connector detail cards with the same data as the Dashboard, plus max-processing-time history chart. @@ -613,9 +619,12 @@ server-side timestamp, so restarts and clock skew are handled. line. Both formats expose the same field names, and the method / path / query / protocol are derived from the request line when only `%r` is logged. -- Table: host, user, time, request (or the derived method / path / query / - protocol), status (coloured badge), size, session ID (when logged). Row - click opens a drawer with the full record. +- Table: host, user, time, the method / path / query / protocol columns + (derived from `%r` when the format logs only the request line), status + (coloured badge), size, session ID (when logged). The raw request line + is not a column — it repeats the derived columns and is by far the + widest; it stays available in the record drawer, which a row click + opens with the full record. ### 5.3 Design system @@ -627,19 +636,27 @@ server-side timestamp, so restarts and clock skew are handled. alone). The accent is otherwise neutral: per-tab hues are reserved for the active navigation item (see §5.1) so the current section is recognisable at a glance, and are never used for content. -- Responsive, below 768 px (and on landscape phones of any width up to - 932 px, via a `max-height` media): management tables (applications, +- Responsive. Phone mode below 900 px (bottom tab bar, management tables + (applications, hosts, users, sessions, connectors, servlets, …) convert to stacked - cards, each cell labelled with its column header; high-volume tables - (server logs, access log, active workers) size to their content so - columns are never squeezed, with the first column pinned while the table - scrolls horizontally — except on the log pages in portrait, where the - wide Time column would dominate the viewport, so the whole table scrolls. - The log pages' long text columns (messages, requests) are given most of + cards, each cell labelled with its column header; kebab menus, full-width + forms and modals) — 900 px rather than the classic 768 px so iPad + portrait and small tablets get it too. Independently, high-volume tables + (server logs, access log, active workers) get the scrollable treatment — + size to content so columns are never squeezed or wrapped, first column + pinned while the table scrolls horizontally — up to 1150 px, because + below that even the thirteen access-log columns do not fit without + squeezing and a horizontal scroll is the better trade; above it the + ordinary desktop tables take over (which also keeps ordinary desktop + windows, however short, on the desktop treatment). The log pages never + pin their first column at any width in that range — the pinned Time + column would dominate the viewport and leave a sliver for the scrolling + content, so the whole log table scrolls. + The log pages' long text columns (messages) are given most of the viewport (90vw portrait, 100vw landscape) because with `table-layout:auto` any extra table width flows into the only wrapping column, keeping rows to one or two lines for information density. - Charts re-flow to a single column; row actions move into an overflow + Charts re-flow to a single column; row actions move into an overflow (kebab) menu; form fields and modals go full width. - The side navigation is an icons-only 60 px rail at all widths where it is shown; in landscape viewports it pops out at full width and label on @@ -1165,13 +1182,14 @@ enough that a lint pass (`--check` via a CI node step, optional) plus the integration tests above gives adequate coverage without a JS test harness. -Mobile checklist (portrait 360/390/414 px and landscape 667/812/932 px, -both themes): no page-level horizontal overflow; management tables render +Mobile checklist (portrait 360/390/414/820 px and landscape 667/812/932 px, +both themes, plus mid-width windows at 901/1024 px): no page-level +horizontal overflow; below 900 px management tables render as labelled stacked cards and row actions collapse into the kebab menu (kebab opens, closes on outside tap and `Esc`, disabled actions stay disabled); high-volume tables (logs, access log, workers) scroll -horizontally with the first column pinned and long values wrapping, not -squeezed; tabs scroll when crowded; page-head, log and diagnostics +horizontally with the first column pinned (log pages: never pinned, the +whole table scrolls) and single-line rows below 1150 px, not squeezed; tabs scroll when crowded; page-head, log and diagnostics controls go full width; modals show stacked full-width buttons; toasts appear above the bottom nav; the bottom nav keeps all nine items as icons only (labels as `aria-label`, active item in its per-tab hue); inputs are diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index 4d76cc3afe4f..0f93dfe52d90 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -234,7 +234,7 @@ button { font: inherit; color: inherit; cursor: pointer; } .icon-btn svg { fill: currentColor; } /* The sidenav is an icon rail at all widths where it is shown (the bottom - tab bar takes over at 768 px and below). In landscape the rail pops out + tab bar takes over at 900 px and below). In landscape the rail pops out to full width and label on hover/focus (see the landscape media below); in portrait it stays collapsed. The content is pinned to the second grid column so the pop-out — position:fixed, out of the grid flow — @@ -924,7 +924,7 @@ pre.block { .grid.charts .col-4 { grid-column: span 12; } } -@media (max-width: 768px) { +@media (max-width: 900px) { .shell-main { grid-template-columns: 1fr; } /* The sidenav is a bottom bar here; the content takes the only column. */ .content { grid-column: 1; } @@ -953,7 +953,7 @@ pre.block { /* ============================ Responsive: phone ======================== */ -@media (max-width: 768px) { +@media (max-width: 900px) { /* Page head: title first, then full width action buttons */ .page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); } .page-head .btn { width: 100%; } @@ -1041,10 +1041,14 @@ pre.block { .icon-btn { min-width: 44px; min-height: 44px; } } -/* Scrollable tables on phones. (max-width: 768px) covers portrait phones; - the landscape media adds the wider landscape phones (769-932 px) whose - shell is still desktop-sized but which need the same table treatment. */ -@media (max-width: 768px), (orientation: landscape) and (max-height: 520px) { +/* Scrollable high-volume tables: size to content (columns are never + squeezed or wrapped) and let the container scroll horizontally, pinning + the first column. This runs up to 1150 px: below that even the thirteen + access-log columns do not fit without wrapping, and the horizontal + scroll is the better trade; above it the ordinary desktop tables take + over. The cap also keeps ordinary desktop windows — however short, e.g. + a vertically split screen — on the desktop treatment. */ +@media (max-width: 1150px) { /* Size to content (floored at the container width) so columns are not squeezed, and keep cells on one line. */ .table-wrap:not(.stackable) table.data { @@ -1078,25 +1082,31 @@ pre.block { .table-wrap code { overflow-wrap: anywhere; } } -/* The log pages are information dense. In portrait the pinned Time column - would dominate the viewport and leave a sliver for the scrolling - content, so let the whole table scroll. And give the message/request - column most of the viewport so typical lines wrap to at most two rows; +/* The log pages are information dense: a pinned first column next to the + wide Time column leaves only a sliver for the scrolling content and + hurts readability, so the log tables never pin the first column — the + whole table scrolls across the entire range where the scrollable + treatment applies (up to the 1150 px desktop switch), in any + orientation. In portrait phones also give the message column most of + the viewport so typical lines wrap to at most two rows; max-width:none drops the 640 px desktop cell cap that would starve the column again. */ -@media (orientation: portrait) and (max-width: 600px) { +@media (max-width: 1150px) { .table-wrap.log-table:not(.stackable) table.data th:first-child, .table-wrap.log-table:not(.stackable) table.data td:first-child { position: static; background: transparent; box-shadow: none; } +} +@media (orientation: portrait) and (max-width: 900px) { .table-wrap.log-table table.data td.wide { min-width: 90vw; max-width: none; } } /* Landscape phones: even more of the viewport for the message/request - column so rows stay one or two lines tall. */ -@media (orientation: landscape) and (max-height: 520px) { + column so rows stay one or two lines tall. Capped at the widest landscape + phone so short desktop windows keep the ordinary desktop table. */ +@media (orientation: landscape) and (max-height: 520px) and (max-width: 932px) { .table-wrap.log-table table.data td.wide { min-width: 100vw; max-width: none; } } @@ -1105,7 +1115,7 @@ pre.block { flyout is position:fixed (out of the grid flow) so the grid and the view are never resized; leaving the rail collapses it again. Portrait viewports keep the rail collapsed (no hover pop-out). */ -@media (orientation: landscape) and (min-width: 769px) { +@media (orientation: landscape) and (min-width: 901px) { .sidenav:hover, .sidenav:focus-within { position: fixed; diff --git a/modules/manager2/webapp/js/logviewer.js b/modules/manager2/webapp/js/logviewer.js index ab724e8df582..251509c11249 100644 --- a/modules/manager2/webapp/js/logviewer.js +++ b/modules/manager2/webapp/js/logviewer.js @@ -132,6 +132,10 @@ function showRecord(row) { function columnsFor(fields) { const cols = []; for (const key of fields) { + // The raw request line duplicates the method / path / query / protocol + // columns derived from it and is the widest column of the table; it + // stays available in the record drawer and in the method filter. + if (key === 'request') continue; const def = COLUMNS[key] || { label: key }; cols.push({ key, diff --git a/modules/manager2/webapp/js/pages/configuration.js b/modules/manager2/webapp/js/pages/configuration.js index d18bfc3384a6..a31f8f1ee73c 100644 --- a/modules/manager2/webapp/js/pages/configuration.js +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -252,7 +252,7 @@ export async function configuration(container) { renderDetail(); // On narrow screens the detail card is stacked below the tree; bring it // into view after a selection so the result is immediately visible. - if (window.matchMedia('(max-width: 768px)').matches) { + if (window.matchMedia('(max-width: 900px)').matches) { detailCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } diff --git a/modules/manager2/webapp/js/pages/diagnostics.js b/modules/manager2/webapp/js/pages/diagnostics.js index b215d9f7756c..1f8faf2e6422 100644 --- a/modules/manager2/webapp/js/pages/diagnostics.js +++ b/modules/manager2/webapp/js/pages/diagnostics.js @@ -256,6 +256,7 @@ export async function diagnostics(container) { } } + loaded[0] = true; await loaders[0](); return null; } diff --git a/modules/manager2/webapp/js/pages/monitoring.js b/modules/manager2/webapp/js/pages/monitoring.js index 203a009f7f3a..c51f05a35eb0 100644 --- a/modules/manager2/webapp/js/pages/monitoring.js +++ b/modules/manager2/webapp/js/pages/monitoring.js @@ -29,6 +29,11 @@ const STAGE_LABELS = { '?': 'Unknown', }; +// Only these stages are doing real work; R (ready), K (keep-alive) and '?' +// are idle one way or another and are left out of the table. +const ACTIVE_STAGES = new Set(['P', 'S', 'F']); +const STAGE_BADGES = { P: 'info', S: 'ok', F: 'warn' }; + export async function monitoring(container) { const view = el('div', {}, el('div', { class: 'page-head' }, @@ -102,14 +107,16 @@ export async function monitoring(container) { function renderWorkers(workers) { clear(workersWrap); + const active = workers.filter((w) => ACTIVE_STAGES.has(w.stage)); const countBadge = document.getElementById('worker-count'); if (countBadge) { - countBadge.textContent = workers.length + ' active'; + countBadge.textContent = active.length + ' active · ' + + (workers.length - active.length) + ' idle'; } - const rows = workers.map((w) => el('tr', {}, + const rows = active.map((w) => el('tr', {}, el('td', {}, - el('span', { class: 'badge ' + (w.stage === 'S' ? 'ok' : 'plain') }, - w.stage + (w.stage === 'S' ? ' · ' + (STAGE_LABELS[w.stage] || '') : ''))), + el('span', { class: 'badge ' + (STAGE_BADGES[w.stage] || 'plain') }, + w.stage + ' · ' + (STAGE_LABELS[w.stage] || ''))), el('td', { class: 'num' }, w.time != null ? formatMs(w.time) : '-'), el('td', { class: 'num' }, w.bytesSent != null ? formatBytes(w.bytesSent) : '-'), el('td', { class: 'num' }, w.bytesReceived != null ? formatBytes(w.bytesReceived) : '-'), diff --git a/modules/manager2/webapp/js/ui.js b/modules/manager2/webapp/js/ui.js index 6fe24cc94a4f..5eba56ba4dd9 100644 --- a/modules/manager2/webapp/js/ui.js +++ b/modules/manager2/webapp/js/ui.js @@ -329,7 +329,7 @@ export function menu({ trigger, items }) { /** * Build a row action list that collapses into a kebab (overflow) menu on * narrow screens: the buttons always render, CSS hides all of them except - * the kebab below 768 px, and the kebab opens the same actions in a + * the kebab below 900 px, and the kebab opens the same actions in a * dropdown menu. * * @param {Array<{label, class, onclick, disabled, title}>} actions From a875bbac6f76b2c310f8f83beea8f8f7f6cc5972 Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 11:37:19 +0200 Subject: [PATCH 12/17] Add CPU and memory cards to the Monitoring page --- modules/manager2/manager2-design.md | 11 ++ .../tomcat/manager2/StatusApiServlet.java | 7 +- .../tomcat/manager2/StatusSnapshot.java | 168 +++++++++++++++++- .../tomcat/manager2/TestManager2Webapp.java | 32 ++++ modules/manager2/webapp/css/manager2.css | 25 +++ .../manager2/webapp/js/pages/monitoring.js | 117 +++++++++++- 6 files changed, 355 insertions(+), 5 deletions(-) diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 1773649fa3b6..893deaf2a2ce 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -193,6 +193,7 @@ machine-readable `error` code and non-2xx status. | DELETE | `/api/apps/{path}/sessions/{id}/attributes/{name}` | manager-gui | remove one session attribute | | GET | `/api/status` | manager-gui, manager-status | compact live snapshot (see below) | | GET | `/api/status/workers` | manager-gui, manager-status | live per-socket (RequestProcessor) table | +| GET | `/api/status/system` | manager-gui, manager-status | instant CPU and memory snapshot (cores, CPU loads, load average, thread counts; physical memory, swap, heap, non-heap, memory pools) | | GET | `/api/status/apps/{path}` | manager-gui, manager-status | detailed per-app: state, times, sessions, JSPs, servlets | | GET | `/api/ssl/ciphers` | manager-gui | SSL ciphers per connector | | GET | `/api/ssl/certs` | manager-gui | SSL certs per connector | @@ -573,6 +574,16 @@ server-side timestamp, so restarts and clock skew are handled. of the database. **Monitoring** +- CPU and memory status cards (5 s cadence): an instant snapshot from + `GET /api/status/system`, not a chart. The CPU card shows the system and + JVM process CPU load as percentage bars, plus cores, 1-minute load + average and live/daemon/peak thread counts; the memory card shows + physical memory and swap usage, the JVM heap (used / max bar, committed) + and non-heap usage as bars and facts, and a per-memory-pool table + (used, committed, max). Values the platform does not expose (CPU loads + before the first monitoring interval, the load average on Windows, + physical/swap memory on a JVM without the HotSpot management MBean) + render as "-". - Live workers table (5 s cadence): stage, processing time, bytes sent/received, remote address (forwarded + actual), virtual host, request line. Only *active* workers are listed — stages P (parsing, diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java index 3d70b9f66918..2eeb5ba92493 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java @@ -51,8 +51,9 @@ /** - * The Manager2 status API. Serves the compact live snapshot, the live worker table and the detailed per-application - * state. The MBean queries mirror {@link org.apache.catalina.manager.StatusManagerServlet}. + * The Manager2 status API. Serves the compact live snapshot, the instant CPU and memory snapshot, the live worker + * table and the detailed per-application state. The MBean queries mirror + * {@link org.apache.catalina.manager.StatusManagerServlet}. */ public class StatusApiServlet extends HttpServlet implements ContainerServlet, NotificationListener { @@ -230,6 +231,8 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro } else { Api.json(response, history.payload()); } + } else if (path.startsWith("/api/status/system")) { + Api.json(response, StatusSnapshot.system()); } else if (path.startsWith("/api/status")) { Api.json(response, StatusSnapshot.snapshot(mBeanServer, threadPools, host)); } else { diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java index 7a417985ad59..29f4c0bcf9b8 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java @@ -19,6 +19,7 @@ import java.lang.management.ManagementFactory; import java.lang.management.MemoryPoolMXBean; import java.lang.management.MemoryUsage; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -43,6 +44,37 @@ public final class StatusSnapshot { + /* + * The CPU loads, the physical memory and the swap of the machine are only + * exposed by the HotSpot specific MBean + * com.sun.management.OperatingSystemMXBean (jdk.management module). It is + * accessed reflectively rather than directly, for two reasons: + *

    + *
  • JVMs without the module (embedded JVMs, stripped runtime images) + * are handled without any linkage error: the metrics are simply reported + * as unavailable;
  • + *
  • the same code backports cleanly to older Tomcat branches running on + * older Java releases: each accessor names the methods newest first, with + * the names of older releases as fallbacks (the getters for the total / + * free physical memory and the swap space have existed since Java 6, + * getProcessCpuLoad since Java 10, getCpuLoad since Java 14 where it + * replaced getSystemCpuLoad).
  • + *
+ * A resolved method is null when neither name exists on the running JVM, + * which the callers report as an unavailable metric. + */ + + private static final Class SUN_OS_MXBEAN = findSunOsMxBean(); + + private static final Method SUN_CPU_LOAD = sunMethod("getCpuLoad", "getSystemCpuLoad"); + private static final Method SUN_PROCESS_CPU_LOAD = sunMethod("getProcessCpuLoad"); + private static final Method SUN_PHYSICAL_TOTAL = sunMethod("getTotalMemorySize", + "getTotalPhysicalMemorySize"); + private static final Method SUN_PHYSICAL_FREE = sunMethod("getFreeMemorySize", "getFreePhysicalMemorySize"); + private static final Method SUN_SWAP_TOTAL = sunMethod("getTotalSwapSpaceSize"); + private static final Method SUN_SWAP_FREE = sunMethod("getFreeSwapSpaceSize"); + + /** * Build the compact live snapshot served by {@code GET /api/status}. * @@ -136,6 +168,96 @@ public static List> workers(MBeanServer mBeanServer, List system() { + Map result = new LinkedHashMap<>(); + result.put("ts", Long.valueOf(System.currentTimeMillis())); + result.put("cpu", cpu()); + result.put("memory", memory()); + return result; + } + + + private static Map cpu() { + Map result = new LinkedHashMap<>(); + + java.lang.management.OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); + result.put("availableProcessors", Integer.valueOf(os.getAvailableProcessors())); + + // The load average is not available on all platforms (negative = not available). + double loadAverage = os.getSystemLoadAverage(); + result.put("loadAverage", loadAverage >= 0 ? Double.valueOf(loadAverage) : null); + + // The CPU loads are only exposed by the HotSpot specific MBean. They + // are negative before the first monitoring interval completes. + Number systemLoad = sunInvoke(SUN_CPU_LOAD, os); + result.put("systemLoad", systemLoad != null && systemLoad.doubleValue() >= 0 + ? Double.valueOf(systemLoad.doubleValue()) : null); + Number processLoad = sunInvoke(SUN_PROCESS_CPU_LOAD, os); + result.put("processLoad", processLoad != null && processLoad.doubleValue() >= 0 + ? Double.valueOf(processLoad.doubleValue()) : null); + + java.lang.management.ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + result.put("threads", Integer.valueOf(threads.getThreadCount())); + result.put("daemonThreads", Integer.valueOf(threads.getDaemonThreadCount())); + result.put("peakThreads", Integer.valueOf(threads.getPeakThreadCount())); + + return result; + } + + + private static Map memory() { + Map result = new LinkedHashMap<>(); + + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + Map heapMap = new LinkedHashMap<>(); + heapMap.put("used", Long.valueOf(heap.getUsed())); + heapMap.put("committed", Long.valueOf(heap.getCommitted())); + heapMap.put("max", Long.valueOf(heap.getMax())); + result.put("heap", heapMap); + + MemoryUsage nonHeap = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage(); + Map nonHeapMap = new LinkedHashMap<>(); + nonHeapMap.put("used", Long.valueOf(nonHeap.getUsed())); + nonHeapMap.put("committed", Long.valueOf(nonHeap.getCommitted())); + result.put("nonHeap", nonHeapMap); + + // The physical memory and swap of the machine are only exposed by the + // HotSpot specific MBean. + java.lang.management.OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); + Number physicalTotal = sunInvoke(SUN_PHYSICAL_TOTAL, os); + Number physicalFree = sunInvoke(SUN_PHYSICAL_FREE, os); + if (physicalTotal != null && physicalFree != null) { + Map physical = new LinkedHashMap<>(); + physical.put("total", Long.valueOf(physicalTotal.longValue())); + physical.put("free", Long.valueOf(physicalFree.longValue())); + result.put("physical", physical); + } else { + result.put("physical", null); + } + + Number swapTotal = sunInvoke(SUN_SWAP_TOTAL, os); + Number swapFree = sunInvoke(SUN_SWAP_FREE, os); + if (swapTotal != null && swapFree != null) { + Map swap = new LinkedHashMap<>(); + swap.put("total", Long.valueOf(swapTotal.longValue())); + swap.put("free", Long.valueOf(swapFree.longValue())); + result.put("swap", swap); + } else { + result.put("swap", null); + } + + result.put("pools", memoryPools()); + return result; + } + + /** * Build the detailed per-application state served by {@code GET /api/status/apps/{path}}. * @@ -232,6 +354,12 @@ private static Map jvm() { result.put("memory", memory); result.put("nonHeapUsed", Long.valueOf(ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage().getUsed())); + result.put("pools", memoryPools()); + return result; + } + + + private static List> memoryPools() { Map pools = new TreeMap<>(); for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { pools.put(pool.getType().toString() + ":" + pool.getName(), pool); @@ -248,8 +376,7 @@ private static Map jvm() { entry.put("used", Long.valueOf(usage.getUsed())); poolList.add(entry); } - result.put("pools", poolList); - return result; + return poolList; } @@ -335,6 +462,43 @@ private static ObjectName findRequestGroup(MBeanServer mBeanServer, String conne } + private static Class findSunOsMxBean() { + try { + return Class.forName("com.sun.management.OperatingSystemMXBean"); + } catch (ClassNotFoundException | LinkageError e) { + // No HotSpot management MBean on this JVM + return null; + } + } + + + private static Method sunMethod(String... names) { + if (SUN_OS_MXBEAN == null) { + return null; + } + for (String name : names) { + try { + return SUN_OS_MXBEAN.getMethod(name); + } catch (ReflectiveOperationException | LinkageError e) { + // Not in this Java release; try the next (older) name + } + } + return null; + } + + + private static Number sunInvoke(Method method, Object target) { + if (method == null || target == null || !SUN_OS_MXBEAN.isInstance(target)) { + return null; + } + try { + return (Number) method.invoke(target); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + return null; + } + } + + private static Long number(Object value) { if (value instanceof Number n) { return Long.valueOf(n.longValue()); diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java index 7636ef4d9072..64c9d400b73d 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -426,6 +426,38 @@ public void testStatusEndpoints() throws Exception { } + @Test + public void testStatusSystemEndpoint() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + login(client, "manager1"); + + // The instant CPU and memory snapshot. + request(client, "GET", MANAGER2 + "/api/status/system", null, null, 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"cpu\"")); + Assert.assertTrue(body.contains("\"memory\"")); + Assert.assertTrue(body.contains("\"availableProcessors\"")); + Assert.assertTrue(body.contains("\"systemLoad\"")); + Assert.assertTrue(body.contains("\"processLoad\"")); + Assert.assertTrue(body.contains("\"loadAverage\"")); + Assert.assertTrue(body.contains("\"threads\"")); + Assert.assertTrue(body.contains("\"heap\"")); + Assert.assertTrue(body.contains("\"nonHeap\"")); + Assert.assertTrue(body.contains("\"pools\"")); + // The JVM runs on at least one core, so the snapshot must not report + // an unusable machine. + Assert.assertFalse(body.contains("\"availableProcessors\":0")); + // The heap is always present and in use by the running webapp. + Assert.assertFalse(body.contains("\"heap\":{\"used\":0,")); + + client.disconnect(); + } + + @Test public void testStatusHistory() throws Exception { setup(false); diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index 0f93dfe52d90..a23aad340354 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -518,6 +518,31 @@ textarea { resize: vertical; min-height: 90px; font-family: var(--mono); font-si background: var(--bg-inset); margin-top: var(--space-3); } .progress > div { height: 100%; width: 0%; background: var(--accent); border-radius: 99px; transition: width 200ms ease; } +.progress.warn > div { background: var(--warn); } +.progress.danger > div { background: var(--danger); } + +/* CPU / memory instant snapshot cards (Monitoring) */ +.sys-metric { margin-top: var(--space-3); } +.sys-metric:first-child { margin-top: 0; } +.sys-metric-head { + display: flex; justify-content: space-between; gap: var(--space-3); + font-size: 13px; margin-bottom: 6px; +} +.sys-metric-head .sys-label { font-weight: 600; } +.sys-metric-head .sys-value { color: var(--text-soft); font-variant-numeric: tabular-nums; } +.sys-metric .progress { margin-top: 0; } +.sys-facts { + display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-2); margin-top: var(--space-4); +} +.sys-fact { + display: flex; flex-direction: column; gap: 2px; + padding: var(--space-2) var(--space-3); + background: var(--bg-inset); border-radius: var(--radius); +} +.sys-fact-label { font-size: 11.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-faint); } +.sys-fact-value { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } +.sys-body .table-wrap { margin-top: var(--space-4); } /* ============================ Tabs ===================================== */ diff --git a/modules/manager2/webapp/js/pages/monitoring.js b/modules/manager2/webapp/js/pages/monitoring.js index c51f05a35eb0..b74109fae65f 100644 --- a/modules/manager2/webapp/js/pages/monitoring.js +++ b/modules/manager2/webapp/js/pages/monitoring.js @@ -34,13 +34,57 @@ const STAGE_LABELS = { const ACTIVE_STAGES = new Set(['P', 'S', 'F']); const STAGE_BADGES = { P: 'info', S: 'ok', F: 'warn' }; +function pctText(fraction) { + return fraction == null ? '-' : (fraction * 100).toFixed(1) + ' %'; +} + +function usageBar(fraction) { + const bar = el('div', { class: 'progress' }, el('div')); + const fill = bar.firstChild; + if (fraction != null) { + fill.style.width = Math.min(100, Math.max(0, fraction * 100)) + '%'; + if (fraction >= 0.9) bar.classList.add('danger'); + else if (fraction >= 0.75) bar.classList.add('warn'); + } + return bar; +} + +function metric(label, value, fraction) { + return el('div', { class: 'sys-metric' }, + el('div', { class: 'sys-metric-head' }, + el('span', { class: 'sys-label' }, label), + el('span', { class: 'sys-value' }, value)), + usageBar(fraction)); +} + +function fact(label, value) { + return el('div', { class: 'sys-fact' }, + el('span', { class: 'sys-fact-label' }, label), + el('span', { class: 'sys-fact-value' }, value)); +} + export async function monitoring(container) { const view = el('div', {}, el('div', { class: 'page-head' }, el('h1', {}, 'Monitoring'), - el('p', {}, 'Live worker (socket) table. Refreshes every 5 seconds; paused while the tab is hidden.'))); + el('p', {}, 'Instant CPU and memory snapshot, live connectors and worker (socket) table. ' + + 'Refreshes every 5 seconds; paused while the tab is hidden.'))); container.append(view); + const sysGrid = el('div', { class: 'grid charts' }); + const cpuCard = el('div', { class: 'card col-6' }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), 'CPU')), + el('div', { class: 'sys-body' })); + const cpuBody = cpuCard.querySelector('.sys-body'); + const memoryCard = el('div', { class: 'card col-6' }, + el('div', { class: 'card-title-row' }, + el('h3', {}, el('span', { class: 'live-dot' }), 'Memory')), + el('div', { class: 'sys-body' })); + const memoryBody = memoryCard.querySelector('.sys-body'); + sysGrid.append(cpuCard, memoryCard); + view.append(sysGrid); + const connectorsCard = el('div', { class: 'card' }, el('div', { class: 'card-title-row' }, el('h3', {}, el('span', { class: 'live-dot' }), 'Connectors')), @@ -69,6 +113,14 @@ export async function monitoring(container) { return; } + try { + const sys = await get('/api/status/system'); + renderCpu(sys.cpu); + renderMemory(sys.memory); + } catch (err) { + // The cards keep the values of the previous snapshot. + } + let workers; try { workers = await get('/api/status/workers'); @@ -78,6 +130,69 @@ export async function monitoring(container) { renderWorkers(workers); } + function renderCpu(cpu) { + clear(cpuBody); + if (!cpu) { + cpuBody.append(el('div', { class: 'empty' }, 'Not available')); + return; + } + cpuBody.append( + metric('System CPU', pctText(cpu.systemLoad), cpu.systemLoad), + metric('JVM process CPU', pctText(cpu.processLoad), cpu.processLoad), + el('div', { class: 'sys-facts' }, + fact('Cores', String(cpu.availableProcessors)), + fact('Load average', cpu.loadAverage != null ? cpu.loadAverage.toFixed(2) : '-'), + fact('Threads', cpu.threads + ' live'), + fact('Daemon threads', String(cpu.daemonThreads)), + fact('Peak threads', String(cpu.peakThreads)))); + } + + function renderMemory(mem) { + clear(memoryBody); + if (!mem) { + memoryBody.append(el('div', { class: 'empty' }, 'Not available')); + return; + } + const heap = mem.heap || {}; + const nonHeap = mem.nonHeap || {}; + memoryBody.append( + mem.physical && mem.physical.total > 0 + ? metric('Physical memory', + formatBytes(mem.physical.total - mem.physical.free) + ' / ' + + formatBytes(mem.physical.total), + (mem.physical.total - mem.physical.free) / mem.physical.total) + : metric('Physical memory', '-', null), + mem.swap && mem.swap.total > 0 + ? metric('Swap', + formatBytes(mem.swap.total - mem.swap.free) + ' / ' + formatBytes(mem.swap.total), + (mem.swap.total - mem.swap.free) / mem.swap.total) + : metric('Swap', 'none', null), + heap.max > 0 + ? metric('JVM heap', formatBytes(heap.used) + ' / ' + formatBytes(heap.max), + heap.used / heap.max) + : metric('JVM heap', formatBytes(heap.used) + ' (unbounded)', null)); + memoryBody.append(el('div', { class: 'sys-facts' }, + fact('Heap committed', formatBytes(heap.committed)), + fact('Non-heap used', formatBytes(nonHeap.used)))); + + const pools = mem.pools || []; + if (pools.length > 0) { + const rows = pools.map((p) => el('tr', {}, + el('td', {}, el('strong', {}, p.name)), + el('td', { class: 'num' }, formatBytes(p.used)), + el('td', { class: 'num' }, formatBytes(p.committed)), + el('td', { class: 'num' }, p.max >= 0 ? formatBytes(p.max) : '-'))); + memoryBody.append(el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, 'Pool'), + el('th', {}, 'Used'), + el('th', {}, 'Committed'), + el('th', {}, 'Max'))), + el('tbody', {}, rows)))); + } + } + function renderConnectors(connectors) { clear(connectorsWrap); const rows = connectors.map((c) => el('tr', {}, From 8601f9e1403c4bb9c08be99694dc48b3a060d94b Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 13:51:15 +0200 Subject: [PATCH 13/17] Full localization of missing items --- modules/manager2/manager2-design.md | 33 +- .../java/org/apache/tomcat/manager2/Api.java | 3 +- .../tomcat/manager2/AppsApiServlet.java | 68 +- .../tomcat/manager2/ConfigApiServlet.java | 1166 ++++++++--------- .../apache/tomcat/manager2/CsrfFilter.java | 8 +- .../apache/tomcat/manager2/HomeServlet.java | 3 +- .../tomcat/manager2/HostsApiServlet.java | 22 +- .../java/org/apache/tomcat/manager2/Html.java | 95 +- .../apache/tomcat/manager2/I18nServlet.java | 126 ++ .../tomcat/manager2/LocalStrings.properties | 929 +++++++++++++ .../apache/tomcat/manager2/LocaleFilter.java | 48 + .../apache/tomcat/manager2/LoginServlet.java | 3 +- .../tomcat/manager2/LogsApiServlet.java | 15 +- .../tomcat/manager2/StatusApiServlet.java | 17 +- .../org/apache/tomcat/manager2/Strings.java | 171 ++- .../tomcat/manager2/UsersApiServlet.java | 75 +- .../tomcat/manager2/TestManager2Config.java | 29 + .../manager2/TestManager2Descriptions.java | 91 ++ .../tomcat/manager2/TestManager2Webapp.java | 83 ++ modules/manager2/webapp/WEB-INF/web.xml | 24 + modules/manager2/webapp/error-403.html | 11 +- modules/manager2/webapp/error-404.html | 10 +- modules/manager2/webapp/index.html | 14 +- modules/manager2/webapp/js/api.js | 8 +- modules/manager2/webapp/js/i18n.js | 92 ++ modules/manager2/webapp/js/logviewer.js | 121 +- modules/manager2/webapp/js/main.js | 41 +- modules/manager2/webapp/js/pages/accesslog.js | 5 +- modules/manager2/webapp/js/pages/apps.js | 237 ++-- .../manager2/webapp/js/pages/configuration.js | 336 ++--- modules/manager2/webapp/js/pages/dashboard.js | 48 +- .../manager2/webapp/js/pages/diagnostics.js | 62 +- modules/manager2/webapp/js/pages/hosts.js | 85 +- modules/manager2/webapp/js/pages/logs.js | 5 +- .../manager2/webapp/js/pages/monitoring.js | 144 +- modules/manager2/webapp/js/pages/users.js | 219 ++-- modules/manager2/webapp/js/ui.js | 67 +- modules/manager2/webapp/login.html | 19 +- 38 files changed, 3113 insertions(+), 1420 deletions(-) create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/I18nServlet.java create mode 100644 modules/manager2/src/main/java/org/apache/tomcat/manager2/LocaleFilter.java create mode 100644 modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Descriptions.java create mode 100644 modules/manager2/webapp/js/i18n.js diff --git a/modules/manager2/manager2-design.md b/modules/manager2/manager2-design.md index 893deaf2a2ce..bef3fab2b04f 100644 --- a/modules/manager2/manager2-design.md +++ b/modules/manager2/manager2-design.md @@ -152,10 +152,26 @@ Key decisions: 4. **No new Java dependencies** (JDK + existing Tomcat/Jakarta APIs only) and **no JS dependencies** (hand-written ES modules), so the Ant build stays self-contained and the attack surface stays minimal. -5. **i18n** — operation result messages reuse the existing - `org.apache.catalina.manager` StringManager bundles (already translated to - 10 locales). New UI-only strings live in a JS dictionary module - (English first; server messages are shown as-is and already localized). + 5. **i18n** — every user-visible string lives in the + `org.apache.tomcat.manager2` `LocalStrings` bundle. A request-scoped + `LocaleFilter` resolves the browser's `Accept-Language` to a per-locale + `StringManager` (exact match, then language-only, then default), so API + responses and the `#{key}` placeholders of the login / error / SPA + templates — substituted server-side by `Html.render`, which also fills + `{{lang}}` for the `lang` attribute — are localized per request. The + browser loads the `manager2.ui.*` keys (and only those) from the public + `GET /i18n` endpoint and renders its strings through `js/i18n.js`. + Only text inherited from `HTMLManagerServlet` / `HostManagerServlet` + operations still comes from the existing `org.apache.catalina.manager` + bundles (already translated to 10 locales). The descriptions of the + configuration editor are not stored in the code either: they are looked + up dynamically by attribute name (`manager2.attr..` for + component and JNDI entry attributes, `manager2.param.` — + optionally scoped as `manager2.param..` — for the + factory options of a JNDI resource); for the standard attributes these + bundle messages override the English text of the MBean descriptors + shipped with the container, and `TestManager2Descriptions` fails when a + seeded copy drifts from the descriptor it was taken from. ## 4. JSON API @@ -221,7 +237,7 @@ machine-readable `error` code and non-2xx status. | POST | `/api/roles` | manager-gui | `{"rolename", "description"?, "name"?}` create a role (409 when it exists) | | DELETE | `/api/roles/{rolename}?name=` | manager-gui | remove a role and detach it from all users and groups (404 when absent; 400 `SELF_ROLE_REMOVAL` when the signed-in account holds the role, directly or through a group) | | GET | `/api/config/tree` | manager-gui | the live component tree below `Server`: `{"tree": {id, type, className, name, state?, self?, children[]}}`; `self: true` on the context hosting this webapp; a context carries its `manager` (with the manager's `sessionIdGenerator`), `resources`, `loader` and `cookieProcessor` as children (a running context always has all of them); the `Server` and each context carry a single `namingResources` node (the `NamingResourcesImpl`) whose children are the JNDI entries, keyed by JNDI name — `resource`, `resourceLink` (context only), `resourceEnvRef`, `environment`, `ejb`, `localEjb`, `serviceRef`; an engine, host or context that owns a cluster carries a single `cluster` child (an inherited parent cluster is not a child) whose children are the `channel` (holding `membership`, `sender` — with a `transport` child for a replication transmitter —, `receiver` and the repeatable `interceptor` nodes), the repeatable `clusterValve`, the `clusterManager` (with its `sessionIdGenerator`), the repeatable `clusterListener` and the repeatable `listener` | -| GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `lifecycle` (whether the component implements `Lifecycle` and therefore supports the start/stop/restart operations), `affectsSelf` (whether a start or stop of the component would interrupt access to this web application itself — the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context; only a restart is allowed for it), `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description`, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | +| GET | `/api/config/node/{id}` | manager-gui | one node: `id`, `type`, `name`, `className`, `state`, `self`, `lifecycle` (whether the component implements `Lifecycle` and therefore supports the start/stop/restart operations), `affectsSelf` (whether a start or stop of the component would interrupt access to this web application itself — the server, the service/engine/host/context that route it, the connector that serves it and the wrappers of its context; only a restart is allowed for it), `acceptsListener` (whether a lifecycle listener can be added), `acceptsSubRealm` (realm nodes only: whether the realm is a `CombinedRealm` and can hold sub realms), `sslEnabled` (connector nodes) / `isDefault` (SSL host config nodes), `global` (naming resources nodes: `true` for the server, `false` for a context), the bean's attributes as `properties[]` (`name`, `type`, `description` — localized from the message bundle by attribute name, see the i18n note in section 3, `writable`, `value`, and `param` for the free-form JNDI entry parameters) and a `children[]` summary. Every JNDI entry type lists the string parameters it has set (the `ResourceBase` property map, the RefAddr keys the JNDI factories consume) as `param` properties; a JNDI `resource` additionally lists the closed option set (RefAddr keys) of its effective first-party factory (the explicit `factory` parameter, or the factory `ResourceFactory` dispatches the type to, e.g. `javax.sql.DataSource` → `BasicDataSourceFactory`) as `param` properties | | POST | `/api/config/attribute` | manager-gui | `{"id", "name", "value", "confirm"?}` — set one writable property on the live component (the change takes effect immediately); `name`/`path`/`defaultHost` additionally require `confirm` to equal the current value; a TLS attribute of a running, TLS enabled connector is re-validated by reloading the affected host configuration and the previous value is restored (400 `UPDATE_FAILED`) when the new value does not validate. For a JNDI entry, editing an attribute (or a `param` — an empty value removes it) is applied by removing and re-adding the entry (the `NamingContextListener` reacts to the property-change events to rebind the live JNDI environment); the previous state is restored (400 `UPDATE_FAILED`) when the re-add fails (e.g. the new JNDI name is already in use) | | POST | `/api/config/child` | manager-gui | `{"parent", "type", ...}` — add a `service` (created together with an engine of the same name), `host`, `context` (docBase auto-created), `wrapper`, `valve`, `connector`, `executor`, `alias`, `realm` (any container, or a `CombinedRealm` for a sub realm; instantiated from `className`), a context sub component — `manager`, `resources`, `loader` or `cookieProcessor` (parent: a context) or `sessionIdGenerator` (parent: a manager; all instantiated from `className` and **replacing** the current instance, since a context/manager holds exactly one of each) — or `listener` (any parent whose component implements `Lifecycle`; instantiated from `className` and registered, not started); a `cluster` (parent: an engine, host or context) is instantiated from `className` (default `SimpleTcpCluster`) and attached via `setCluster`, which starts the channel and applies the cluster defaults (the addition is rolled back + 400 `START_FAILED` when it cannot start), and its sub components are added by `className` with a sensible default — `channel` (default `GroupChannel`, replacing the current one), `membership` (default `McastService`, parent: the channel), `sender` (default `ReplicationTransmitter`, parent: the channel), `receiver` (default `NioReceiver`, parent: the channel), `interceptor` (parent: the channel, repeatable), `clusterValve` (a `Valve` that is a `ClusterValve`, parent: the cluster, repeatable; 400 `INVALID_CLASS` otherwise), `deployer` (default `FarmWarDeployer`, parent: the cluster), `clusterManager` (default `DeltaManager`, parent: the cluster), `transport` (default `PooledParallelSender`, parent: the sender) and `clusterListener` (parent: the cluster, repeatable); the single-valued slots replace the current instance; structural components are started immediately and the addition rolled back when the start fails. Replacing a context's `manager` or `loader` on a running context stops the old instance and starts the new one (rolled back + 400 `START_FAILED` when it does not start); replacing the `resources` of a running context is refused (400 `CONTEXT_RUNNING` — stop the context first); replacing the `manager` or `loader` of this webapp's own context is refused (403 `SELF_COMPONENT` — it would destroy the admin session / the running classes). A `sslHostConfig` (parent: a connector) optionally carries an initial `certificate` object; on a running connector the TLS configuration is validated and applied without a restart (400 `ADD_FAILED` + rollback otherwise); the first certificate is required for a running connector (400 `INVALID_VALUE`). A further `certificate` is added with the crypto type in the `type` field (`RSA`, `DSA`, ...). An `upgradeProtocol` (parent: a connector) is instantiated from `className` (default `org.apache.coyote.http2.Http2Protocol`, the only `UpgradeProtocol` shipped with Tomcat) and registered through `AbstractHttp11Protocol.addUpgradeProtocol`; a connector whose protocol handler is not the HTTP/1.1 variant does not accept one (400 `BAD_PARENT`), a class that is not an `UpgradeProtocol` is 400 `INVALID_CLASS`, a second protocol with the same name (e.g. a second `h2`) is 409 `DUPLICATE`; an upgrade protocol is only referenced when the connector is initialised, so no live activation is attempted — the protocol becomes active the next time the connector is restarted. A JNDI entry (`parent`: a `namingResources` node) — `resource`, `resourceLink` (refused at the server level, 400 `BAD_PARENT`), `resourceEnvRef`, `environment`, `ejb`, `localEjb` or `serviceRef` — requires `name` and `jndiType`; a `resourceLink` additionally requires `global`; a `factory` (a `resource` parameter / `resourceLink` attribute) must be loadable (400 `INVALID_CLASS`); a `params` object carries the entry's generic string parameters (the `ResourceBase` property map) for any entry type — validated against the closed option set of a first-party factory for a `resource`, free-form otherwise; a JNDI name already in use is 409 `DUPLICATE`, a missing `jndiType` 400 `MISSING_FIELD`; the entry is registered and bound in the live JNDI environment at once | | DELETE | `/api/config/child` | manager-gui | `{"id", "confirm"?}` — remove a component (containers are stopped recursively); `confirm` must equal the component's display name for `host`/`context`/`service`/`engine`/`connector`/`executor`/`wrapper`/`valve`/`sslHostConfig`/`upgradeProtocol`/`realm`/`cluster` and for the JNDI entries (`resource`/`resourceLink`/`resourceEnvRef`/`environment`/`ejb`/`localEjb`/`serviceRef`, whose JNDI name is unbound from the live JNDI environment); 400 `LAST_SERVICE`, 403 `SELF_COMPONENT`, 400 `BASIC_COMPONENT`, 400 `NOT_EMPTY`, 400 `LAST_REALM` (the container would be left without a realm), 400 `REQUIRED_COMPONENT` (the context's `manager`/`resources`/`loader`/`cookieProcessor`, a manager's `sessionIdGenerator` and the `namingResources` node itself are required and cannot be removed), 400 `SSL_DEFAULT` and 400 `SSL_LAST_CERTIFICATE` guards apply; removing the last SSL host configuration of a running, TLS enabled connector switches it back to plain HTTP; a `cluster` (which stops the cluster and its channel) and its single-valued children (`channel`, `membership`, `sender`, `receiver`, `deployer`, `clusterManager`, `transport`, `clusterListener` and the static `member`) can be detached from their parent, but a `clusterValve` or `interceptor` has no removal API on a running cluster and is refused (400 `REMOVE_NOT_SUPPORTED`) | @@ -942,7 +958,12 @@ modules/manager2/ HeadersFilter.java CSP / Referrer-Policy Strings.java StringManager creation that finds the web app's LocalStrings bundle regardless of the initializing - thread's context class loader + thread's context class loader; binds the request + locale (LocaleFilter) to the current thread + LocaleFilter.java Maps Accept-Language to the StringManager locale + for the duration of the request + I18nServlet.java GET /i18n — serves the manager2.ui.* bundle keys + as JSON for the client Api.java, Json.java, Constants.java LocalStrings.properties src/test/java/org/apache/tomcat/manager2/ diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java index 6a7fa2f9cde3..ff963ab3ce59 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java @@ -96,7 +96,8 @@ public static void error(HttpServletResponse response, int status, String code, * @throws IOException if a write error occurs */ public static void notFound(HttpServletResponse response) throws IOException { - error(response, HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", "Resource not found"); + error(response, HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + Strings.sm().getString("manager2.notFound")); } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java index dd029add5733..f41cd6f2917a 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java @@ -57,12 +57,6 @@ public class AppsApiServlet extends HTMLManagerServlet { private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { try { @@ -241,7 +235,7 @@ private void handleLifecycle(HttpServletRequest request, HttpServletResponse res try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } String message = invoke(pw -> { @@ -265,7 +259,7 @@ private void handleUndeploy(HttpServletRequest request, HttpServletResponse resp try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } String message = invoke(pw -> super.undeploy(pw, cn, legacySm(request))); @@ -281,7 +275,7 @@ private void handleExpire(HttpServletRequest request, HttpServletResponse respon try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } Map body = readJson(request); @@ -318,7 +312,7 @@ private void handleDeploy(HttpServletRequest request, HttpServletResponse respon cn = ContextName.extractFromPath(war); } else { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } @@ -332,19 +326,18 @@ private void handleDeploy(HttpServletRequest request, HttpServletResponse respon private void handleUpload(HttpServletRequest request, HttpServletResponse response) throws IOException { StringManager smClient = legacySm(request); - StringManager smLegacy = StringManager.getManager("org.apache.catalina.manager", request.getLocales()); try { Part warPart = request.getPart("war"); if (warPart == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UPLOAD_NO_FILE", - smLegacy.getString("htmlManagerServlet.deployUploadNoFile")); + Strings.sm().getString("manager2.uploadNoFile")); return; } String filename = warPart.getSubmittedFileName(); if (filename == null || !filename.toLowerCase(Locale.ENGLISH).endsWith(".war")) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UPLOAD_NOT_WAR", - smLegacy.getString("htmlManagerServlet.deployUploadNotWar", filename)); + Strings.sm().getString("manager2.uploadNotWar", filename)); return; } int slash = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\')); @@ -374,7 +367,7 @@ private void handleUpload(HttpServletRequest request, HttpServletResponse respon Context existing = (Context) host.findChild(name); if (existing != null && !replace) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "ALREADY_DEPLOYED", - smLegacy.getString("managerServlet.alreadyContext", cn.getDisplayName())); + Strings.sm().getString("manager2.alreadyDeployed", cn.getDisplayName())); return; } @@ -386,7 +379,7 @@ private void handleUpload(HttpServletRequest request, HttpServletResponse respon File target = replace ? new File(deployedWar.getAbsolutePath() + ".tmp") : deployedWar; if (!replace && target.exists()) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "WAR_EXISTS", - smLegacy.getString("htmlManagerServlet.deployUploadWarExists", filename)); + Strings.sm().getString("manager2.uploadWarExists", filename)); return; } @@ -396,12 +389,12 @@ private void handleUpload(HttpServletRequest request, HttpServletResponse respon if (replace) { if (deployedWar.exists() && !deployedWar.delete()) { Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "DELETE_FAILED", - smLegacy.getString("managerServlet.deleteFail", deployedWar)); + Strings.sm().getString("manager2.uploadDeleteFailed", deployedWar)); return; } if (!target.renameTo(deployedWar)) { Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "RENAME_FAILED", - smLegacy.getString("managerServlet.renameFail", target, deployedWar)); + Strings.sm().getString("manager2.uploadRenameFailed", target, deployedWar)); return; } } @@ -411,26 +404,26 @@ private void handleUpload(HttpServletRequest request, HttpServletResponse respon check(name); } else { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "IN_SERVICE", - smLegacy.getString("managerServlet.inService", cn.getDisplayName())); + Strings.sm().getString("manager2.contextInService", cn.getDisplayName())); return; } Context deployed = (Context) host.findChild(name); String message; if (deployed != null && deployed.getConfigured() && deployed.getState().isAvailable()) { - message = smLegacy.getString("managerServlet.deployed", cn.getDisplayName()); + message = Strings.sm().getString("manager2.deployed", cn.getDisplayName()); } else if (deployed != null && !deployed.getState().isAvailable()) { - message = smLegacy.getString("managerServlet.deployedButNotStarted", cn.getDisplayName()); + message = Strings.sm().getString("manager2.deployedNotStarted", cn.getDisplayName()); } else { - message = smLegacy.getString("managerServlet.deployFailed", cn.getDisplayName()); + message = Strings.sm().getString("manager2.deployFailed", cn.getDisplayName()); } Api.ok(response, message); } catch (IllegalArgumentException e) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); } catch (Exception e) { - log(sm.getString("manager2.error.upload"), e); + log(Strings.sm().getString("manager2.error.upload"), e); Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "UPLOAD_FAILED", - smLegacy.getString("htmlManagerServlet.deployUploadFail", e.getMessage())); + Strings.sm().getString("manager2.uploadFailed", e.getMessage())); } } @@ -440,7 +433,7 @@ private void handleSessionsList(HttpServletRequest request, HttpServletResponse try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } StringManager smClient = legacySm(request); @@ -485,7 +478,7 @@ private void handleSessionDetail(HttpServletRequest request, HttpServletResponse try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } String path = path(request); @@ -499,7 +492,7 @@ private void handleSessionDetail(HttpServletRequest request, HttpServletResponse Session session = getSessionForNameAndId(cn, sessionId, legacySm(request)); if (session == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "SESSION_NOT_FOUND", - sm.getString("manager2.sessionNotFound", sessionId)); + Strings.sm().getString("manager2.sessionNotFound", sessionId)); return; } Map payload = sessionToJson(session); @@ -516,7 +509,7 @@ private void handleInvalidate(HttpServletRequest request, HttpServletResponse re try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } Map body = readJson(request); @@ -530,7 +523,7 @@ private void handleInvalidate(HttpServletRequest request, HttpServletResponse re Map payload = new LinkedHashMap<>(); payload.put("ok", Boolean.TRUE); payload.put("count", Integer.valueOf(count)); - payload.put("message", count + " sessions invalidated."); + payload.put("message", Strings.sm().getString("manager2.sessionsInvalidated", Integer.valueOf(count))); Api.json(response, payload); } catch (IllegalArgumentException e) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage()); @@ -543,7 +536,7 @@ private void handleRemoveAttribute(HttpServletRequest request, HttpServletRespon try { if (cn == null) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", - sm.getString("manager2.missingPath")); + Strings.sm().getString("manager2.missingPath")); return; } String path = path(request); @@ -559,10 +552,10 @@ private void handleRemoveAttribute(HttpServletRequest request, HttpServletRespon boolean removed = removeSessionAttribute(cn, sessionId, attributeName, legacySm(request)); if (removed) { - Api.ok(response, sm.getString("manager2.attributeRemoved", attributeName)); + Api.ok(response, Strings.sm().getString("manager2.attributeRemoved", attributeName)); } else { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND", - sm.getString("manager2.attributeNotFound", attributeName)); + Strings.sm().getString("manager2.attributeNotFound", attributeName)); } } catch (IllegalArgumentException e) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage()); @@ -706,7 +699,7 @@ private static Map readJson(HttpServletRequest request) throws I try { return new JSONParser(body).parseObject(); } catch (Exception e) { - throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", e.getMessage()), e); } } @@ -720,13 +713,6 @@ private String invoke(Operation operation) { } - // Keep it since it can be useful eventually - @SuppressWarnings("unused") - private static StringManager clientSm(HttpServletRequest request) { - return StringManager.getManager(Constants.Package, request.getLocales()); - } - - /** * A StringManager for the legacy manager message bundle. All the inherited operations report their results using * the {@code org.apache.catalina.manager} strings, so that bundle has to be used for localized output. @@ -744,12 +730,12 @@ private static boolean pathCheck(File input, File expected, HttpServletResponse try { if (!input.getCanonicalFile().toPath().startsWith(expected.getCanonicalFile().toPath())) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "PATH_CHECK_FAILED", - sm.getString("manager2.pathCheckFail", input, expected)); + Strings.sm().getString("manager2.pathCheckFail", input, expected)); return false; } } catch (IOException ioe) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "PATH_CHECK_ERROR", - sm.getString("manager2.pathCheckError", input, expected, ioe.getMessage())); + Strings.sm().getString("manager2.pathCheckError", input, expected, ioe.getMessage())); return false; } return true; diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 09262d5bfef3..54b94e2befb8 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -134,7 +134,6 @@ import org.apache.tomcat.util.net.AbstractEndpoint; import org.apache.tomcat.util.net.SSLHostConfig; import org.apache.tomcat.util.net.SSLHostConfigCertificate; -import org.apache.tomcat.util.res.StringManager; /** @@ -262,12 +261,6 @@ public class ConfigApiServlet extends HttpServlet implements ContainerServlet { private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - /** * A component name that may be used for new hosts, wrappers, executors and services (and that is accepted for * engines). @@ -361,7 +354,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } catch (ConfigException e) { Api.error(response, e.status, e.code, e.getMessage()); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ServletException(e); } } @@ -392,7 +385,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } catch (IllegalArgumentException e) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ServletException(e); } } @@ -417,7 +410,7 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response } catch (IllegalArgumentException e) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ServletException(e); } } @@ -1155,14 +1148,20 @@ private Map node(NodeRef ref) throws ConfigException { // descriptor branch because the registry falls back to an // introspected ManagedBean for them, which would otherwise // shadow the (more complete) explicit list. - List explicit = explicitAttributes(component, ref.type); - if (!explicit.isEmpty()) { - for (ExplicitAttribute attribute : explicit) { + ExplicitSpec explicit = explicitAttributes(component, ref.type); + if (!explicit.attributes().isEmpty()) { + // Factory options of a resource are localized per factory (the + // same parameter name may carry a different text depending on + // the factory that consumes it). + String paramScope = "resource".equals(ref.type) && component instanceof ContextResource resource + ? factoryKey(effectiveFactory(resource)) : null; + for (ExplicitAttribute attribute : explicit.attributes()) { Map entry = new LinkedHashMap<>(); entry.put("name", attribute.getName()); entry.put("type", attribute.getType()); - if (attribute.getDescription() != null) { - entry.put("description", attribute.getDescription()); + String description = attributeDescription(explicit.scope(), paramScope, attribute); + if (description != null) { + entry.put("description", description); } entry.put("writable", Boolean.valueOf(attribute.isWritable())); if (attribute.isParam()) { @@ -1181,8 +1180,16 @@ private Map node(NodeRef ref) throws ConfigException { Map entry = new LinkedHashMap<>(); entry.put("name", attribute.getName()); entry.put("type", attribute.getType()); - if (attribute.getDescription() != null) { - entry.put("description", attribute.getDescription()); + // The bundle wins when it defines the attribute (the + // hook that lets the standard, descriptor-provided + // descriptions be localized); otherwise the descriptor + // text is used as-is. + String description = Strings.sm().getString("manager2.attr." + ref.type + "." + attribute.getName()); + if (description == null) { + description = attribute.getDescription(); + } + if (description != null) { + entry.put("description", description); } entry.put("writable", Boolean.valueOf(attribute.isWriteable() && EDITABLE_TYPES.contains(attribute.getType()))); @@ -1252,6 +1259,8 @@ private static Object readValue(Object component, AttributeInfo attribute) { * One explicitly defined attribute of a component whose class has no modeler MBean descriptor (the TLS components * {@code SSLHostConfig}/{@code SSLHostConfigCertificate} and the context sub components {@code WebappLoader}, * {@code CookieProcessorBase} and {@code SessionIdGeneratorBase}), defined here instead of being derived. + * Descriptions are not carried here: they are looked up from the message bundle by scope and attribute name (see + * {@link #attributeDescription}). */ private static final class ExplicitAttribute { @@ -1261,21 +1270,18 @@ private static final class ExplicitAttribute { private final boolean writable; - private final String description; - private final boolean param; - ExplicitAttribute(String name, String type, boolean writable, String description) { - this(name, type, writable, description, false); + ExplicitAttribute(String name, String type, boolean writable) { + this(name, type, writable, false); } - ExplicitAttribute(String name, String type, boolean writable, String description, boolean param) { + ExplicitAttribute(String name, String type, boolean writable, boolean param) { this.name = name; this.type = type; this.writable = writable; - this.description = description; this.param = param; } @@ -1295,11 +1301,6 @@ boolean isWritable() { } - String getDescription() { - return description; - } - - /** * {@code true} for attributes that are not bean properties but string parameters of the generic property map of * a JNDI entry (the {@code ResourceBase} properties). @@ -1311,93 +1312,64 @@ boolean isParam() { private static final List SSL_HOST_CONFIG_ATTRIBUTES = List.of( - new ExplicitAttribute("hostName", "java.lang.String", false, - "The SNI host name this configuration applies to (lower case)."), - new ExplicitAttribute("protocols", "java.lang.String", true, - "Enabled TLS protocols, e.g. TLSv1.2+TLSv1.3, or All."), - new ExplicitAttribute("certificateVerification", "java.lang.String", true, - "Client certificate verification: none, optional, optionalNoCA or required."), - new ExplicitAttribute("certificateVerificationDepth", "int", true, - "The depth of the client certificate chain verification."), - new ExplicitAttribute("ciphers", "java.lang.String", true, - "The cipher list for TLS 1.2 and below (OpenSSL or JSSE names)."), - new ExplicitAttribute("cipherSuites", "java.lang.String", true, "The cipher suite list for TLS 1.3."), - new ExplicitAttribute("honorCipherOrder", "boolean", true, "Whether to honor the server cipher order."), - new ExplicitAttribute("sessionCacheSize", "int", true, "The SSL session cache size."), - new ExplicitAttribute("sessionTimeout", "int", true, "The SSL session timeout in seconds."), - new ExplicitAttribute("groups", "java.lang.String", true, "The enabled named groups (comma separated)."), - new ExplicitAttribute("keyManagerAlgorithm", "java.lang.String", true, "The key manager algorithm (JSSE)."), - new ExplicitAttribute("sslProtocol", "java.lang.String", true, "The SSL protocol (JSSE)."), - new ExplicitAttribute("revocationEnabled", "boolean", true, - "Whether CRL/OCSP revocation checking is enabled (JSSE)."), - new ExplicitAttribute("trustManagerClassName", "java.lang.String", true, - "The trust manager class name (JSSE)."), - new ExplicitAttribute("truststoreAlgorithm", "java.lang.String", true, "The truststore algorithm (JSSE)."), - new ExplicitAttribute("truststoreFile", "java.lang.String", true, "The truststore file (JSSE)."), - new ExplicitAttribute("truststorePassword", "java.lang.String", true, "The truststore password (JSSE)."), - new ExplicitAttribute("truststoreProvider", "java.lang.String", true, "The truststore provider (JSSE)."), - new ExplicitAttribute("truststoreType", "java.lang.String", true, "The truststore type (JSSE)."), - new ExplicitAttribute("caCertificateFile", "java.lang.String", true, "The CA certificate file (OpenSSL)."), - new ExplicitAttribute("caCertificatePath", "java.lang.String", true, - "The CA certificate directory (OpenSSL)."), - new ExplicitAttribute("certificateRevocationListPath", "java.lang.String", true, - "The certificate revocation list directory (OpenSSL)."), - new ExplicitAttribute("disableCompression", "boolean", true, - "Whether TLS compression is disabled (OpenSSL)."), - new ExplicitAttribute("disableSessionTickets", "boolean", true, - "Whether TLS session tickets are disabled (OpenSSL)."), - new ExplicitAttribute("insecureRenegotiation", "boolean", true, - "Whether insecure renegotiation is allowed (OpenSSL)")); + new ExplicitAttribute("hostName", "java.lang.String", false), + new ExplicitAttribute("protocols", "java.lang.String", true), + new ExplicitAttribute("certificateVerification", "java.lang.String", true), + new ExplicitAttribute("certificateVerificationDepth", "int", true), + new ExplicitAttribute("ciphers", "java.lang.String", true), + new ExplicitAttribute("cipherSuites", "java.lang.String", true), + new ExplicitAttribute("honorCipherOrder", "boolean", true), + new ExplicitAttribute("sessionCacheSize", "int", true), + new ExplicitAttribute("sessionTimeout", "int", true), + new ExplicitAttribute("groups", "java.lang.String", true), + new ExplicitAttribute("keyManagerAlgorithm", "java.lang.String", true), + new ExplicitAttribute("sslProtocol", "java.lang.String", true), + new ExplicitAttribute("revocationEnabled", "boolean", true), + new ExplicitAttribute("trustManagerClassName", "java.lang.String", true), + new ExplicitAttribute("truststoreAlgorithm", "java.lang.String", true), + new ExplicitAttribute("truststoreFile", "java.lang.String", true), + new ExplicitAttribute("truststorePassword", "java.lang.String", true), + new ExplicitAttribute("truststoreProvider", "java.lang.String", true), + new ExplicitAttribute("truststoreType", "java.lang.String", true), + new ExplicitAttribute("caCertificateFile", "java.lang.String", true), + new ExplicitAttribute("caCertificatePath", "java.lang.String", true), + new ExplicitAttribute("certificateRevocationListPath", "java.lang.String", true), + new ExplicitAttribute("disableCompression", "boolean", true), + new ExplicitAttribute("disableSessionTickets", "boolean", true), + new ExplicitAttribute("insecureRenegotiation", "boolean", true)); private static final List CERTIFICATE_ATTRIBUTES = List.of( - new ExplicitAttribute("type", "java.lang.String", false, - "The certificate type (the default certificate has no type)."), - new ExplicitAttribute("certificateKeystoreFile", "java.lang.String", true, - "The keystore file (JKS or PKCS12)."), - new ExplicitAttribute("certificateKeystorePassword", "java.lang.String", true, "The keystore password."), - new ExplicitAttribute("certificateKeystorePasswordFile", "java.lang.String", true, - "The file that contains the keystore password."), - new ExplicitAttribute("certificateKeystoreType", "java.lang.String", true, - "The keystore type (e.g. PKCS12)."), - new ExplicitAttribute("certificateKeystoreProvider", "java.lang.String", true, "The keystore provider."), - new ExplicitAttribute("certificateKeyAlias", "java.lang.String", true, - "The alias of the key entry in the keystore."), - new ExplicitAttribute("certificateKeyPassword", "java.lang.String", true, - "The private key password (if different from the keystore password)."), - new ExplicitAttribute("certificateKeyPasswordFile", "java.lang.String", true, - "The file that contains the private key password."), - new ExplicitAttribute("certificateFile", "java.lang.String", true, "The certificate file (PEM, OpenSSL)."), - new ExplicitAttribute("certificateChainFile", "java.lang.String", true, - "The certificate chain file (PEM, OpenSSL)."), - new ExplicitAttribute("certificateKeyFile", "java.lang.String", true, - "The private key file (PEM, OpenSSL)")); + new ExplicitAttribute("type", "java.lang.String", false), + new ExplicitAttribute("certificateKeystoreFile", "java.lang.String", true), + new ExplicitAttribute("certificateKeystorePassword", "java.lang.String", true), + new ExplicitAttribute("certificateKeystorePasswordFile", "java.lang.String", true), + new ExplicitAttribute("certificateKeystoreType", "java.lang.String", true), + new ExplicitAttribute("certificateKeystoreProvider", "java.lang.String", true), + new ExplicitAttribute("certificateKeyAlias", "java.lang.String", true), + new ExplicitAttribute("certificateKeyPassword", "java.lang.String", true), + new ExplicitAttribute("certificateKeyPasswordFile", "java.lang.String", true), + new ExplicitAttribute("certificateFile", "java.lang.String", true), + new ExplicitAttribute("certificateChainFile", "java.lang.String", true), + new ExplicitAttribute("certificateKeyFile", "java.lang.String", true)); private static final List WEBAPP_LOADER_ATTRIBUTES = List.of( - new ExplicitAttribute("delegate", "boolean", true, - "Whether the web application class loader delegates to the parent class loader first."), - new ExplicitAttribute("loaderClass", "java.lang.String", true, - "The class of the web application class loader instance."), - new ExplicitAttribute("jakartaConverter", "java.lang.String", true, - "The class that converts Jakarta Servlet API classes to their equivalent in the deployed application.")); + new ExplicitAttribute("delegate", "boolean", true), + new ExplicitAttribute("loaderClass", "java.lang.String", true), + new ExplicitAttribute("jakartaConverter", "java.lang.String", true)); private static final List COOKIE_PROCESSOR_ATTRIBUTES = List.of( - new ExplicitAttribute("cookiesWithoutEquals", "java.lang.String", true, - "How to handle cookie names without an equals sign in the cookie header."), - new ExplicitAttribute("sameSiteCookies", "java.lang.String", true, - "The SameSite attribute added to the cookies of this web application (Unset, None, Lax or Strict)."), - new ExplicitAttribute("partitioned", "boolean", true, - "Whether the Partitioned attribute is added to the cookies of this web application.")); + new ExplicitAttribute("cookiesWithoutEquals", "java.lang.String", true), + new ExplicitAttribute("sameSiteCookies", "java.lang.String", true), + new ExplicitAttribute("partitioned", "boolean", true)); private static final List SESSION_ID_GENERATOR_ATTRIBUTES = List.of( - new ExplicitAttribute("secureRandomClass", "java.lang.String", true, - "The secure random number generator class used to create the session ids."), - new ExplicitAttribute("jvmRoute", "java.lang.String", true, - "The jvm route appended to the generated session ids (cluster failover)."), - new ExplicitAttribute("sessionIdLength", "int", true, "The length of the generated session ids in bytes.")); + new ExplicitAttribute("secureRandomClass", "java.lang.String", true), + new ExplicitAttribute("jvmRoute", "java.lang.String", true), + new ExplicitAttribute("sessionIdLength", "int", true)); // The HTTP/2 upgrade protocol has no modeler descriptor either. Only the @@ -1405,44 +1377,28 @@ boolean isParam() { // components). All changes take effect when the owning connector is // (re)started, the same as the protocol itself. private static final List HTTP2_PROTOCOL_ATTRIBUTES = List.of( - new ExplicitAttribute("readTimeout", "long", true, "The socket level read timeout in milliseconds."), - new ExplicitAttribute("writeTimeout", "long", true, "The socket level write timeout in milliseconds."), - new ExplicitAttribute("keepAliveTimeout", "long", true, "The keep alive timeout in milliseconds."), - new ExplicitAttribute("streamReadTimeout", "long", true, "The stream level read timeout in milliseconds."), - new ExplicitAttribute("streamWriteTimeout", "long", true, - "The stream level write timeout in milliseconds."), - new ExplicitAttribute("maxConcurrentStreams", "long", true, - "The maximum number of concurrent streams per connection."), - new ExplicitAttribute("maxConcurrentStreamExecution", "int", true, - "The maximum number of concurrently executing streams per connection."), - new ExplicitAttribute("initialWindowSize", "int", true, - "The initial window size advertised to the client in bytes."), - new ExplicitAttribute("useSendfile", "boolean", true, "Whether to use sendfile for file transfers."), - new ExplicitAttribute("allowSchemeMismatch", "boolean", true, - "Whether HTTP/2 streams may provide a scheme that does not match the transport."), - new ExplicitAttribute("maxHeaderCount", "int", true, "The maximum number of headers allowed per request."), - new ExplicitAttribute("maxHeaderSize", "int", false, - "The maximum size of request headers in bytes (set on the HTTP/1.1 protocol handler)."), - new ExplicitAttribute("maxTrailerCount", "int", true, - "The maximum number of trailer headers allowed per request."), - new ExplicitAttribute("maxTrailerSize", "int", false, - "The maximum size of trailer headers in bytes (set on the HTTP/1.1 protocol handler)."), - new ExplicitAttribute("overheadCountFactor", "int", true, - "The overhead count factor used for overhead frame tracking."), - new ExplicitAttribute("overheadResetFactor", "int", true, - "The overhead reset factor used for RST frame tracking."), - new ExplicitAttribute("overheadContinuationThreshold", "int", true, - "The payload size threshold for CONTINUATION frame overhead tracking in bytes."), - new ExplicitAttribute("overheadDataThreshold", "int", true, - "The payload size threshold for DATA frame overhead tracking in bytes."), - new ExplicitAttribute("overheadWindowUpdateThreshold", "int", true, - "The payload size threshold for WINDOW_UPDATE frame overhead tracking in bytes."), - new ExplicitAttribute("initiatePingDisabled", "boolean", true, - "Whether the periodic PING frames that keep the connection alive are disabled."), - new ExplicitAttribute("discardRequestsAndResponses", "boolean", true, - "Whether requests and responses are discarded after processing instead of being recycled."), - new ExplicitAttribute("drainTimeout", "long", true, - "The additional time in nanoseconds between the first and the final GOAWAY while a connection is drained.")); + new ExplicitAttribute("readTimeout", "long", true), + new ExplicitAttribute("writeTimeout", "long", true), + new ExplicitAttribute("keepAliveTimeout", "long", true), + new ExplicitAttribute("streamReadTimeout", "long", true), + new ExplicitAttribute("streamWriteTimeout", "long", true), + new ExplicitAttribute("maxConcurrentStreams", "long", true), + new ExplicitAttribute("maxConcurrentStreamExecution", "int", true), + new ExplicitAttribute("initialWindowSize", "int", true), + new ExplicitAttribute("useSendfile", "boolean", true), + new ExplicitAttribute("allowSchemeMismatch", "boolean", true), + new ExplicitAttribute("maxHeaderCount", "int", true), + new ExplicitAttribute("maxHeaderSize", "int", false), + new ExplicitAttribute("maxTrailerCount", "int", true), + new ExplicitAttribute("maxTrailerSize", "int", false), + new ExplicitAttribute("overheadCountFactor", "int", true), + new ExplicitAttribute("overheadResetFactor", "int", true), + new ExplicitAttribute("overheadContinuationThreshold", "int", true), + new ExplicitAttribute("overheadDataThreshold", "int", true), + new ExplicitAttribute("overheadWindowUpdateThreshold", "int", true), + new ExplicitAttribute("initiatePingDisabled", "boolean", true), + new ExplicitAttribute("discardRequestsAndResponses", "boolean", true), + new ExplicitAttribute("drainTimeout", "long", true)); // --------------------------------- Cluster channel attributes @@ -1453,76 +1409,62 @@ boolean isParam() { // defined explicitly. Only the common, documented knobs are listed. private static final List CHANNEL_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The name of the channel."), - new ExplicitAttribute("heartbeat", "boolean", true, - "Whether the channel manages its own heartbeat thread."), - new ExplicitAttribute("heartbeatSleeptime", "long", true, - "The interval in milliseconds between heartbeats."), - new ExplicitAttribute("jmxDomain", "java.lang.String", true, "The JMX domain for the channel components."), - new ExplicitAttribute("jmxPrefix", "java.lang.String", true, - "The JMX name prefix for the channel components."), - new ExplicitAttribute("optionCheck", "boolean", true, - "Whether to check that the channel is correctly configured before starting.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("heartbeat", "boolean", true), + new ExplicitAttribute("heartbeatSleeptime", "long", true), + new ExplicitAttribute("jmxDomain", "java.lang.String", true), + new ExplicitAttribute("jmxPrefix", "java.lang.String", true), + new ExplicitAttribute("optionCheck", "boolean", true)); private static final List MCAST_ATTRIBUTES = List.of( - new ExplicitAttribute("address", "java.lang.String", true, - "The multicast address to join (e.g. 228.0.0.4)."), - new ExplicitAttribute("port", "int", true, "The multicast port to join (e.g. 45564)."), - new ExplicitAttribute("frequency", "long", true, - "The interval in milliseconds between membership messages."), - new ExplicitAttribute("dropTime", "long", true, - "The time in milliseconds a member may be silent before being dropped."), - new ExplicitAttribute("ttl", "int", true, "The time to live for multicast packets."), - new ExplicitAttribute("soTimeout", "int", true, "The socket timeout in milliseconds."), - new ExplicitAttribute("recoveryEnabled", "boolean", true, "Whether membership recovery is enabled."), - new ExplicitAttribute("recoverySleepTime", "long", true, - "The sleep time in milliseconds between recovery attempts."), - new ExplicitAttribute("localLoopbackDisabled", "boolean", true, - "Whether local loopback of multicast packets is disabled.")); + new ExplicitAttribute("address", "java.lang.String", true), + new ExplicitAttribute("port", "int", true), + new ExplicitAttribute("frequency", "long", true), + new ExplicitAttribute("dropTime", "long", true), + new ExplicitAttribute("ttl", "int", true), + new ExplicitAttribute("soTimeout", "int", true), + new ExplicitAttribute("recoveryEnabled", "boolean", true), + new ExplicitAttribute("recoverySleepTime", "long", true), + new ExplicitAttribute("localLoopbackDisabled", "boolean", true)); private static final List RECEIVER_ATTRIBUTES = List.of( - new ExplicitAttribute("port", "int", true, "The TCP port to listen on."), - new ExplicitAttribute("autoBind", "int", true, "The number of attempts to auto bind an available port."), - new ExplicitAttribute("address", "java.lang.String", true, "The address to bind to."), - new ExplicitAttribute("udpPort", "int", true, "The UDP port to listen on."), - new ExplicitAttribute("maxThreads", "int", true, "The maximum number of listener threads."), - new ExplicitAttribute("minThreads", "int", true, "The minimum number of listener threads."), - new ExplicitAttribute("selectorTimeout", "long", true, "The selector timeout in milliseconds."), - new ExplicitAttribute("tcpNoDelay", "boolean", true, "Whether TCP_NODELAY is set on the sockets."), - new ExplicitAttribute("soKeepAlive", "boolean", true, "Whether SO_KEEPALIVE is set on the sockets."), - new ExplicitAttribute("soReuseAddress", "boolean", true, "Whether SO_REUSEADDR is set on the sockets.")); + new ExplicitAttribute("port", "int", true), + new ExplicitAttribute("autoBind", "int", true), + new ExplicitAttribute("address", "java.lang.String", true), + new ExplicitAttribute("udpPort", "int", true), + new ExplicitAttribute("maxThreads", "int", true), + new ExplicitAttribute("minThreads", "int", true), + new ExplicitAttribute("selectorTimeout", "long", true), + new ExplicitAttribute("tcpNoDelay", "boolean", true), + new ExplicitAttribute("soKeepAlive", "boolean", true), + new ExplicitAttribute("soReuseAddress", "boolean", true)); private static final List TRANSPORT_ATTRIBUTES = List.of( - new ExplicitAttribute("poolSize", "int", true, "The number of sockets in the pool (pooled senders)."), - new ExplicitAttribute("timeout", "long", true, "The socket timeout in milliseconds."), - new ExplicitAttribute("maxRetryAttempts", "int", true, "The number of attempts to retransmit a message."), - new ExplicitAttribute("udpPort", "int", true, "The UDP port to send to."), - new ExplicitAttribute("directBuffer", "boolean", true, "Whether to use direct (off heap) buffers."), - new ExplicitAttribute("tcpNoDelay", "boolean", true, "Whether TCP_NODELAY is set on the sockets."), - new ExplicitAttribute("soKeepAlive", "boolean", true, "Whether SO_KEEPALIVE is set on the sockets."), - new ExplicitAttribute("soReuseAddress", "boolean", true, "Whether SO_REUSEADDR is set on the sockets.")); + new ExplicitAttribute("poolSize", "int", true), + new ExplicitAttribute("timeout", "long", true), + new ExplicitAttribute("maxRetryAttempts", "int", true), + new ExplicitAttribute("udpPort", "int", true), + new ExplicitAttribute("directBuffer", "boolean", true), + new ExplicitAttribute("tcpNoDelay", "boolean", true), + new ExplicitAttribute("soKeepAlive", "boolean", true), + new ExplicitAttribute("soReuseAddress", "boolean", true)); private static final List MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES = List.of( - new ExplicitAttribute("maxQueueSize", "long", true, "The maximum number of messages to queue."), - new ExplicitAttribute("maxThreads", "int", true, "The maximum number of threads in the dispatch pool."), - new ExplicitAttribute("maxSpareThreads", "int", true, "The maximum number of idle threads to keep."), - new ExplicitAttribute("keepAliveTime", "long", true, - "The keep alive time in milliseconds for idle threads."), - new ExplicitAttribute("useDeepClone", "boolean", true, "Whether messages are deep cloned before dispatch."), - new ExplicitAttribute("alwaysSend", "boolean", true, - "Whether to always send messages even with no other members.")); + new ExplicitAttribute("maxQueueSize", "long", true), + new ExplicitAttribute("maxThreads", "int", true), + new ExplicitAttribute("maxSpareThreads", "int", true), + new ExplicitAttribute("keepAliveTime", "long", true), + new ExplicitAttribute("useDeepClone", "boolean", true), + new ExplicitAttribute("alwaysSend", "boolean", true)); private static final List TCP_FAILURE_DETECTOR_ATTRIBUTES = List.of( - new ExplicitAttribute("connectTimeout", "long", true, - "The timeout in milliseconds for the connection test."), - new ExplicitAttribute("readTestTimeout", "long", true, "The timeout in milliseconds for the read test."), - new ExplicitAttribute("performSendTest", "boolean", true, "Whether to perform a send test."), - new ExplicitAttribute("performReadTest", "boolean", true, "Whether to perform a read test."), - new ExplicitAttribute("removeSuspectsTimeout", "int", true, - "The time in milliseconds a suspect member is kept before removal.")); + new ExplicitAttribute("connectTimeout", "long", true), + new ExplicitAttribute("readTestTimeout", "long", true), + new ExplicitAttribute("performSendTest", "boolean", true), + new ExplicitAttribute("performReadTest", "boolean", true), + new ExplicitAttribute("removeSuspectsTimeout", "int", true)); - private static final List INTERCEPTOR_ATTRIBUTES = List.of(new ExplicitAttribute("optionFlag", - "int", true, "The option flag that controls the behaviour of the interceptor.")); + private static final List INTERCEPTOR_ATTRIBUTES = List.of(new ExplicitAttribute("optionFlag", "int", true)); // ------------------------------------------------- JNDI entry attributes @@ -1530,81 +1472,58 @@ boolean isParam() { // The JNDI entry types (children of a namingResources node). private static final List RESOURCE_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the resource (e.g. jdbc/MyDB)."), - new ExplicitAttribute("type", "java.lang.String", true, - "The type of the object to look up (e.g. javax.sql.DataSource)."), - new ExplicitAttribute("auth", "java.lang.String", true, - "The JNDI authentication mode (Application or Container)."), - new ExplicitAttribute("scope", "java.lang.String", true, - "The JNDI scope of the resource (Shareable or Unshareable)."), - new ExplicitAttribute("singleton", "boolean", true, - "Whether the resource is a shared, long lived instance."), - new ExplicitAttribute("closeMethod", "java.lang.String", true, - "The method invoked to close the resource when it is unbound."), - new ExplicitAttribute("lookupName", "java.lang.String", true, - "A JNDI name to look up; when set, the other parameters are ignored."), - new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("auth", "java.lang.String", true), + new ExplicitAttribute("scope", "java.lang.String", true), + new ExplicitAttribute("singleton", "boolean", true), + new ExplicitAttribute("closeMethod", "java.lang.String", true), + new ExplicitAttribute("lookupName", "java.lang.String", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List RESOURCE_LINK_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The local JNDI name of the resource link."), - new ExplicitAttribute("type", "java.lang.String", true, "The type of the object the link resolves to."), - new ExplicitAttribute("global", "java.lang.String", true, - "The JNDI name of the (global) resource the link points to."), - new ExplicitAttribute("factory", "java.lang.String", true, - "The JNDI ObjectFactory used to resolve the global resource."), - new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource link.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("global", "java.lang.String", true), + new ExplicitAttribute("factory", "java.lang.String", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List RESOURCE_ENV_REF_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the resource."), - new ExplicitAttribute("type", "java.lang.String", true, "The type of the object to look up."), - new ExplicitAttribute("override", "boolean", true, - "Whether the context environment entry overrides a global resource with the same name."), - new ExplicitAttribute("description", "java.lang.String", true, "The description of the resource.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("override", "boolean", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List ENVIRONMENT_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the environment entry."), - new ExplicitAttribute("type", "java.lang.String", true, - "The type of the entry (e.g. java.lang.String, javax.sql.DataSource)."), - new ExplicitAttribute("value", "java.lang.String", true, "The value of the entry."), - new ExplicitAttribute("override", "boolean", true, - "Whether the context entry overrides a global entry with the same name."), - new ExplicitAttribute("description", "java.lang.String", true, "The description of the entry.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("value", "java.lang.String", true), + new ExplicitAttribute("override", "boolean", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List EJB_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the EJB reference."), - new ExplicitAttribute("type", "java.lang.String", true, "The fully qualified name of the home interface."), - new ExplicitAttribute("home", "java.lang.String", true, - "The fully qualified name of the home interface (alternative to type)."), - new ExplicitAttribute("link", "java.lang.String", true, - "The JNDI name of the remote EJB the reference links to."), - new ExplicitAttribute("remote", "java.lang.String", true, - "The fully qualified name of the remote interface."), - new ExplicitAttribute("description", "java.lang.String", true, "The description of the EJB reference.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("home", "java.lang.String", true), + new ExplicitAttribute("link", "java.lang.String", true), + new ExplicitAttribute("remote", "java.lang.String", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List LOCAL_EJB_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the local EJB reference."), - new ExplicitAttribute("type", "java.lang.String", true, - "The fully qualified name of the local home interface."), - new ExplicitAttribute("local", "java.lang.String", true, - "The fully qualified name of the local business interface."), - new ExplicitAttribute("home", "java.lang.String", true, - "The fully qualified name of the local home interface (alternative to type)."), - new ExplicitAttribute("link", "java.lang.String", true, - "The JNDI name of the local EJB the reference links to."), - new ExplicitAttribute("description", "java.lang.String", true, - "The description of the local EJB reference.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("local", "java.lang.String", true), + new ExplicitAttribute("home", "java.lang.String", true), + new ExplicitAttribute("link", "java.lang.String", true), + new ExplicitAttribute("description", "java.lang.String", true)); private static final List SERVICE_ATTRIBUTES = List.of( - new ExplicitAttribute("name", "java.lang.String", true, "The JNDI name of the service reference."), - new ExplicitAttribute("type", "java.lang.String", true, - "The fully qualified name of the service interface."), - new ExplicitAttribute("interface", "java.lang.String", true, - "The fully qualified name of the service interface (alternative to type)."), - new ExplicitAttribute("displayname", "java.lang.String", true, - "The display name of the service reference."), - new ExplicitAttribute("wsdlfile", "java.lang.String", true, "The WSDL document of the service."), - new ExplicitAttribute("description", "java.lang.String", true, - "The description of the service reference.")); + new ExplicitAttribute("name", "java.lang.String", true), + new ExplicitAttribute("type", "java.lang.String", true), + new ExplicitAttribute("interface", "java.lang.String", true), + new ExplicitAttribute("displayname", "java.lang.String", true), + new ExplicitAttribute("wsdlfile", "java.lang.String", true), + new ExplicitAttribute("description", "java.lang.String", true)); // The first party JNDI ObjectFactory implementations shipped with // Tomcat and the string parameters (RefAddr keys) each one consumes. @@ -1620,127 +1539,111 @@ boolean isParam() { private static final String SHARED_POOL_DATA_SOURCE_FACTORY = "org.apache.tomcat.dbcp.dbcp2.datasources.SharedPoolDataSourceFactory"; private static final List BASIC_DATA_SOURCE_FACTORY_OPTIONS = List.of( - option("defaultAutoCommit", "boolean", "The default auto commit mode of the connections."), - option("defaultReadOnly", "boolean", "The default read only mode of the connections."), - option("defaultTransactionIsolation", "java.lang.String", - "The default transaction isolation level (NONE, READ_COMMITTED, READ_UNCOMMITTED, REPEATABLE_READ, SERIALIZABLE or a JDBC constant)."), - option("defaultCatalog", "java.lang.String", "The default catalog of the connections."), - option("defaultSchema", "java.lang.String", "The default schema of the connections."), - option("cacheState", "boolean", "Whether to cache the connection state on the wrapper."), - option("driverClassName", "java.lang.String", "The JDBC driver class name."), - option("lifo", "boolean", "Whether to allocate idle connections in LIFO order."), - option("maxTotal", "int", "The maximum number of active connections in the pool."), - option("maxIdle", "int", "The maximum number of idle connections in the pool."), - option("minIdle", "int", "The minimum number of idle connections to retain."), - option("initialSize", "int", "The number of connections created at pool startup."), - option("maxWaitMillis", "long", "The maximum time in milliseconds to wait for a connection."), - option("testOnCreate", "boolean", "Whether to validate a connection when it is created."), - option("testOnBorrow", "boolean", "Whether to validate a connection when it is borrowed."), - option("testOnReturn", "boolean", "Whether to validate a connection when it is returned."), - option("timeBetweenEvictionRunsMillis", "long", "The time in milliseconds between eviction runs."), - option("numTestsPerEvictionRun", "int", "The number of connections tested per eviction run."), - option("minEvictableIdleTimeMillis", "long", - "The minimum idle time in milliseconds before a connection is evicted."), - option("softMinEvictableIdleTimeMillis", "long", "The soft minimum idle time in milliseconds."), - option("evictionPolicyClassName", "java.lang.String", "The class of the idle object eviction policy."), - option("testWhileIdle", "boolean", "Whether to validate connections while they are idle."), - option("password", "java.lang.String", "The JDBC connection password."), - option("url", "java.lang.String", "The JDBC connection URL."), - option("username", "java.lang.String", "The JDBC connection user name."), - option("validationQuery", "java.lang.String", - "The SQL query (or callable statement) used to validate connections."), - option("validationQueryTimeout", "long", "The timeout in seconds for the validation query."), - option("connectionInitSqls", "java.lang.String", - "Semicolon separated statements executed on each new connection."), - option("accessToUnderlyingConnectionAllowed", "boolean", - "Whether the underlying driver connection can be obtained."), - option("removeAbandonedOnBorrow", "boolean", "Whether abandoned connections are removed on borrow."), - option("removeAbandonedOnMaintenance", "boolean", - "Whether abandoned connections are removed during maintenance."), - option("removeAbandonedTimeout", "long", - "The timeout in seconds after which a connection is considered abandoned."), - option("logAbandoned", "boolean", "Whether to log the stack trace of abandoned connections."), - option("abandonedUsageTracking", "java.lang.String", - "The stack trace tracking mode for abandoned connections."), - option("poolPreparedStatements", "boolean", "Whether prepared statements are pooled."), - option("clearStatementPoolOnReturn", "boolean", - "Whether the statement pool is cleared when the connection is returned."), - option("maxOpenPreparedStatements", "int", - "The maximum number of pooled prepared statements per connection."), - option("connectionProperties", "java.lang.String", - "Semicolon separated key=value pairs passed to the driver."), - option("maxConnLifetimeMillis", "long", "The maximum lifetime in milliseconds of a connection."), - option("logExpiredConnections", "boolean", "Whether to log the expiration of pooled connections."), - option("rollbackOnReturn", "boolean", "Whether to roll back uncommitted transactions on return."), - option("enableAutoCommitOnReturn", "boolean", "Whether to re-enable auto commit on return."), - option("defaultQueryTimeout", "long", "The default query timeout in seconds."), - option("fastFailValidation", "boolean", "Whether validation fails fast on a known dead connection."), - option("disconnectionSqlCodes", "java.lang.String", - "Comma separated SQL state codes treated as disconnections."), - option("disconnectionIgnoreSqlCodes", "java.lang.String", - "Comma separated SQL state codes ignored during disconnection checks."), - option("jmxName", "java.lang.String", "The JMX ObjectName under which the pool is registered."), - option("registerConnectionMBean", "boolean", "Whether each connection is registered as an MBean."), - option("connectionFactoryClassName", "java.lang.String", - "A custom connection factory class (instead of the driver).")); + option("defaultAutoCommit", "boolean"), + option("defaultReadOnly", "boolean"), + option("defaultTransactionIsolation", "java.lang.String"), + option("defaultCatalog", "java.lang.String"), + option("defaultSchema", "java.lang.String"), + option("cacheState", "boolean"), + option("driverClassName", "java.lang.String"), + option("lifo", "boolean"), + option("maxTotal", "int"), + option("maxIdle", "int"), + option("minIdle", "int"), + option("initialSize", "int"), + option("maxWaitMillis", "long"), + option("testOnCreate", "boolean"), + option("testOnBorrow", "boolean"), + option("testOnReturn", "boolean"), + option("timeBetweenEvictionRunsMillis", "long"), + option("numTestsPerEvictionRun", "int"), + option("minEvictableIdleTimeMillis", "long"), + option("softMinEvictableIdleTimeMillis", "long"), + option("evictionPolicyClassName", "java.lang.String"), + option("testWhileIdle", "boolean"), + option("password", "java.lang.String"), + option("url", "java.lang.String"), + option("username", "java.lang.String"), + option("validationQuery", "java.lang.String"), + option("validationQueryTimeout", "long"), + option("connectionInitSqls", "java.lang.String"), + option("accessToUnderlyingConnectionAllowed", "boolean"), + option("removeAbandonedOnBorrow", "boolean"), + option("removeAbandonedOnMaintenance", "boolean"), + option("removeAbandonedTimeout", "long"), + option("logAbandoned", "boolean"), + option("abandonedUsageTracking", "java.lang.String"), + option("poolPreparedStatements", "boolean"), + option("clearStatementPoolOnReturn", "boolean"), + option("maxOpenPreparedStatements", "int"), + option("connectionProperties", "java.lang.String"), + option("maxConnLifetimeMillis", "long"), + option("logExpiredConnections", "boolean"), + option("rollbackOnReturn", "boolean"), + option("enableAutoCommitOnReturn", "boolean"), + option("defaultQueryTimeout", "long"), + option("fastFailValidation", "boolean"), + option("disconnectionSqlCodes", "java.lang.String"), + option("disconnectionIgnoreSqlCodes", "java.lang.String"), + option("jmxName", "java.lang.String"), + option("registerConnectionMBean", "boolean"), + option("connectionFactoryClassName", "java.lang.String")); private static final List MEMORY_USER_DATABASE_FACTORY_OPTIONS = List.of( - option("pathname", "java.lang.String", "The path of the XML user file (default conf/tomcat-users.xml)."), - option("readonly", "boolean", "Whether the user database is read only."), - option("watchSource", "boolean", "Whether the user file is watched for changes and reloaded.")); + option("pathname", "java.lang.String"), + option("readonly", "boolean"), + option("watchSource", "boolean")); private static final List DATA_SOURCE_USER_DATABASE_FACTORY_OPTIONS = List.of( - option("dataSourceName", "java.lang.String", "The JNDI name of the DataSource to use."), - option("readonly", "boolean", "Whether the user database is read only."), - option("userTable", "java.lang.String", "The name of the user table."), - option("groupTable", "java.lang.String", "The name of the group table."), - option("roleTable", "java.lang.String", "The name of the role table."), - option("userRoleTable", "java.lang.String", "The name of the user/role mapping table."), - option("userGroupTable", "java.lang.String", "The name of the user/group mapping table."), - option("groupRoleTable", "java.lang.String", "The name of the group/role mapping table."), - option("roleNameCol", "java.lang.String", "The column name of the role name."), - option("roleAndGroupDescriptionCol", "java.lang.String", "The column name of the role/group description."), - option("groupNameCol", "java.lang.String", "The column name of the group name."), - option("userCredCol", "java.lang.String", "The column name of the user credential (password)."), - option("userFullNameCol", "java.lang.String", "The column name of the user full name."), - option("userNameCol", "java.lang.String", "The column name of the user name.")); + option("dataSourceName", "java.lang.String"), + option("readonly", "boolean"), + option("userTable", "java.lang.String"), + option("groupTable", "java.lang.String"), + option("roleTable", "java.lang.String"), + option("userRoleTable", "java.lang.String"), + option("userGroupTable", "java.lang.String"), + option("groupRoleTable", "java.lang.String"), + option("roleNameCol", "java.lang.String"), + option("roleAndGroupDescriptionCol", "java.lang.String"), + option("groupNameCol", "java.lang.String"), + option("userCredCol", "java.lang.String"), + option("userFullNameCol", "java.lang.String"), + option("userNameCol", "java.lang.String")); private static final List POOL_DATA_SOURCE_FACTORY_OPTIONS = List.of( - option("instanceKey", "java.lang.String", - "The unique key of this pool instance (defaults to the JNDI name)."), - option("description", "java.lang.String", "A description of the pool."), - option("loginTimeout", "int", "The login timeout in seconds."), - option("blockWhenExhausted", "boolean", "Whether to block when the pool is exhausted."), - option("evictionPolicyClassName", "java.lang.String", "The class of the idle object eviction policy."), - option("lifo", "boolean", "Whether to allocate idle connections in LIFO order."), - option("maxIdlePerKey", "int", "The maximum number of idle connections per instance key."), - option("maxTotalPerKey", "int", "The maximum number of active connections per instance key."), - option("maxWaitMillis", "long", "The maximum time in milliseconds to wait for a connection."), - option("minEvictableIdleTimeMillis", "long", - "The minimum idle time in milliseconds before a connection is evicted."), - option("minIdlePerKey", "int", "The minimum number of idle connections per instance key."), - option("numTestsPerEvictionRun", "int", "The number of connections tested per eviction run."), - option("softMinEvictableIdleTimeMillis", "long", "The soft minimum idle time in milliseconds."), - option("testOnCreate", "boolean", "Whether to validate a connection when it is created."), - option("testOnBorrow", "boolean", "Whether to validate a connection when it is borrowed."), - option("testOnReturn", "boolean", "Whether to validate a connection when it is returned."), - option("testWhileIdle", "boolean", "Whether to validate connections while they are idle."), - option("timeBetweenEvictionRunsMillis", "long", "The time in milliseconds between eviction runs."), - option("validationQuery", "java.lang.String", "The SQL query used to validate connections."), - option("validationQueryTimeout", "int", "The timeout in seconds for the validation query."), - option("rollbackAfterValidation", "boolean", "Whether to roll back after a validation query."), - option("maxConnLifetimeMillis", "long", "The maximum lifetime in milliseconds of a connection."), - option("defaultAutoCommit", "boolean", "The default auto commit mode of the connections."), - option("defaultTransactionIsolation", "int", "The default transaction isolation level (JDBC constant)."), - option("defaultReadOnly", "boolean", "The default read only mode of the connections.")); + option("instanceKey", "java.lang.String"), + option("description", "java.lang.String"), + option("loginTimeout", "int"), + option("blockWhenExhausted", "boolean"), + option("evictionPolicyClassName", "java.lang.String"), + option("lifo", "boolean"), + option("maxIdlePerKey", "int"), + option("maxTotalPerKey", "int"), + option("maxWaitMillis", "long"), + option("minEvictableIdleTimeMillis", "long"), + option("minIdlePerKey", "int"), + option("numTestsPerEvictionRun", "int"), + option("softMinEvictableIdleTimeMillis", "long"), + option("testOnCreate", "boolean"), + option("testOnBorrow", "boolean"), + option("testOnReturn", "boolean"), + option("testWhileIdle", "boolean"), + option("timeBetweenEvictionRunsMillis", "long"), + option("validationQuery", "java.lang.String"), + option("validationQueryTimeout", "int"), + option("rollbackAfterValidation", "boolean"), + option("maxConnLifetimeMillis", "long"), + option("defaultAutoCommit", "boolean"), + option("defaultTransactionIsolation", "int"), + option("defaultReadOnly", "boolean")); private static final List PER_USER_POOL_DATA_SOURCE_FACTORY_OPTIONS = poolOptions( - option("defaultMaxTotal", "int", "The default maximum number of connections per user."), - option("defaultMaxIdle", "int", "The default maximum number of idle connections per user."), - option("defaultMaxWaitMillis", "long", "The default maximum wait time in milliseconds per user.")); + option("defaultMaxTotal", "int"), + option("defaultMaxIdle", "int"), + option("defaultMaxWaitMillis", "long")); private static final List SHARED_POOL_DATA_SOURCE_FACTORY_OPTIONS = poolOptions( - option("maxTotal", "int", "The maximum number of active connections in the pool.")); + option("maxTotal", "int")); private static List poolOptions(ExplicitAttribute... first) { List result = new ArrayList<>(Arrays.asList(first)); @@ -1748,8 +1651,8 @@ private static List poolOptions(ExplicitAttribute... first) { return List.copyOf(result); } - private static ExplicitAttribute option(String name, String type, String description) { - return new ExplicitAttribute(name, type, true, description, true); + private static ExplicitAttribute option(String name, String type) { + return new ExplicitAttribute(name, type, true, true); } @@ -1839,7 +1742,7 @@ private static void appendParams(ResourceBase entry, List res for (Iterator it = entry.listProperties(); it.hasNext();) { String key = it.next(); if (covered.add(key)) { - result.add(new ExplicitAttribute(key, "java.lang.String", true, null, true)); + result.add(new ExplicitAttribute(key, "java.lang.String", true, true)); } } } @@ -1849,55 +1752,114 @@ private static void appendParams(ResourceBase entry, List res * The explicitly defined attributes of the given component, or an empty list when the component's class has a * modeler descriptor (or is not one of the explicitly supported classes). */ - private static List explicitAttributes(Object component, String type) { + private static ExplicitSpec explicitAttributes(Object component, String type) { if ("sslHostConfig".equals(type)) { - return SSL_HOST_CONFIG_ATTRIBUTES; + return new ExplicitSpec("sslHostConfig", SSL_HOST_CONFIG_ATTRIBUTES); } if ("certificate".equals(type)) { - return CERTIFICATE_ATTRIBUTES; + return new ExplicitSpec("certificate", CERTIFICATE_ATTRIBUTES); } if (component instanceof WebappLoader) { - return WEBAPP_LOADER_ATTRIBUTES; + return new ExplicitSpec("loader", WEBAPP_LOADER_ATTRIBUTES); } if (component instanceof CookieProcessorBase) { - return COOKIE_PROCESSOR_ATTRIBUTES; + return new ExplicitSpec("cookieProcessor", COOKIE_PROCESSOR_ATTRIBUTES); } if (component instanceof SessionIdGeneratorBase) { - return SESSION_ID_GENERATOR_ATTRIBUTES; + return new ExplicitSpec("sessionIdGenerator", SESSION_ID_GENERATOR_ATTRIBUTES); } if (component instanceof Http2Protocol) { - return HTTP2_PROTOCOL_ATTRIBUTES; + return new ExplicitSpec("http2Protocol", HTTP2_PROTOCOL_ATTRIBUTES); } if (component instanceof GroupChannel) { - return CHANNEL_ATTRIBUTES; + return new ExplicitSpec("channel", CHANNEL_ATTRIBUTES); } if (component instanceof McastService) { - return MCAST_ATTRIBUTES; + return new ExplicitSpec("membership", MCAST_ATTRIBUTES); } if (component instanceof ReceiverBase) { - return RECEIVER_ATTRIBUTES; + return new ExplicitSpec("receiver", RECEIVER_ATTRIBUTES); } if (component instanceof AbstractSender) { - return TRANSPORT_ATTRIBUTES; + return new ExplicitSpec("transport", TRANSPORT_ATTRIBUTES); } if (component instanceof MessageDispatchInterceptor) { - return MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES; + return new ExplicitSpec("messageDispatchInterceptor", MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES); } if (component instanceof TcpFailureDetector) { - return TCP_FAILURE_DETECTOR_ATTRIBUTES; + return new ExplicitSpec("tcpFailureDetector", TCP_FAILURE_DETECTOR_ATTRIBUTES); } if (component instanceof ChannelInterceptor) { - return INTERCEPTOR_ATTRIBUTES; + return new ExplicitSpec("channelInterceptor", INTERCEPTOR_ATTRIBUTES); } if (isNamingEntry(type) && component instanceof ResourceBase) { - return namingEntryAttributes(component, type); + return new ExplicitSpec(type, namingEntryAttributes(component, type)); } - return List.of(); + return new ExplicitSpec(type, List.of()); + } + + + /** + * The explicit attribute list of a component together with the key scope its descriptions are looked up under + * (see {@link #attributeDescription}). + */ + private record ExplicitSpec(String scope, List attributes) { + } + + + /** + * The localized description of one explicitly defined attribute, looked up from the message bundle by its + * {@code name}: {@code manager2.attr..} for a bean attribute; for a string parameter, + * {@code manager2.param..} first (a factory with a closed parameter set may give the name its + * own text) and then {@code manager2.param.}. + * + * @param scope the key scope of the attribute list the attribute belongs to + * @param paramScope the factory key scope for the parameters of a resource bound to a first party factory, or + * {@code null} + * @param attribute the attribute + * + * @return the localized description, or {@code null} when the bundle defines no message for the attribute + */ + private static String attributeDescription(String scope, String paramScope, ExplicitAttribute attribute) { + var sm = Strings.sm(); + if (attribute.isParam()) { + if (paramScope != null) { + String scoped = sm.getString("manager2.param." + paramScope + "." + attribute.getName()); + if (scoped != null) { + return scoped; + } + } + return sm.getString("manager2.param." + attribute.getName()); + } + return sm.getString("manager2.attr." + scope + "." + attribute.getName()); + } + + + /** + * The bundle key scope of the parameters of a first party JNDI factory (see + * {@link #attributeDescription(String, String, ExplicitAttribute)}). + * + * @param factory the factory class name, possibly {@code null} + * + * @return the key scope of the factory, or {@code null} for a factory that is not shipped with Tomcat + */ + private static String factoryKey(String factory) { + if (factory == null) { + return null; + } + return switch (factory) { + case BASIC_DATA_SOURCE_FACTORY -> "basicDataSource"; + case MEMORY_USER_DATABASE_FACTORY -> "memoryUserDatabase"; + case DATA_SOURCE_USER_DATABASE_FACTORY -> "dataSourceUserDatabase"; + case PER_USER_POOL_DATA_SOURCE_FACTORY -> "perUserPoolDataSource"; + case SHARED_POOL_DATA_SOURCE_FACTORY -> "sharedPoolDataSource"; + default -> null; + }; } private static ExplicitAttribute findExplicitAttribute(Object component, String type, String name) { - for (ExplicitAttribute attribute : explicitAttributes(component, type)) { + for (ExplicitAttribute attribute : explicitAttributes(component, type).attributes()) { if (attribute.getName().equals(name)) { return attribute; } @@ -1961,15 +1923,15 @@ private static void setExplicitValue(Object component, ExplicitAttribute attribu m.invoke(component, value); } catch (NoSuchMethodException e) { throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", - sm.getString("manager2.configNoSetter", method)); + Strings.sm().getString("manager2.configNoSetter", method)); } catch (InvocationTargetException e) { Throwable t = e.getTargetException(); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", - sm.getString("manager2.configSetFailed", attribute.getName(), + Strings.sm().getString("manager2.configSetFailed", attribute.getName(), t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage())); } catch (IllegalAccessException e) { throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", - sm.getString("manager2.configNoSetter", method)); + Strings.sm().getString("manager2.configNoSetter", method)); } } @@ -2044,13 +2006,13 @@ private NodeRef resolve(String id) throws ConfigException { if (id == null || id.isEmpty()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", - sm.getString("manager2.configInvalidId")); + Strings.sm().getString("manager2.configInvalidId")); } String[] segments = id.split("/", -1); if (!"server".equals(segments[0])) { throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", - sm.getString("manager2.configNotFound")); + Strings.sm().getString("manager2.configNotFound")); } Object current = server; @@ -2059,7 +2021,7 @@ private NodeRef resolve(String id) throws ConfigException { for (int i = 1; i < segments.length; i += 2) { if (i + 1 >= segments.length) { throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", - sm.getString("manager2.configNotFound")); + Strings.sm().getString("manager2.configNotFound")); } String kind = segments[i]; String value = dec(segments[i + 1]); @@ -2626,7 +2588,7 @@ private NodeRef resolve(String id) throws ConfigException { private static ConfigException notFound() { return new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", - sm.getString("manager2.configNotFound")); + Strings.sm().getString("manager2.configNotFound")); } @@ -2648,19 +2610,19 @@ private void updateAttribute(HttpServletResponse response, Map b String name = string(body.get("name")); if (id == null || name == null || name.isEmpty()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", - sm.getString("manager2.configInvalidId")); + Strings.sm().getString("manager2.configInvalidId")); } NodeRef ref = resolve(id); if (!"server".equals(ref.type) && ref.component == selfContext && ("name".equals(name) || "path".equals(name))) { throw new ConfigException(HttpServletResponse.SC_FORBIDDEN, "SELF_COMPONENT", - sm.getString("manager2.configSelfComponent")); + Strings.sm().getString("manager2.configSelfComponent")); } // Components without a modeler descriptor take their attributes // from the explicit list. - if (!explicitAttributes(ref.component, ref.type).isEmpty()) { + if (!explicitAttributes(ref.component, ref.type).attributes().isEmpty()) { updateExplicitAttribute(response, ref, name, body); return; } @@ -2669,11 +2631,11 @@ private void updateAttribute(HttpServletResponse response, Map b AttributeInfo attribute = findAttribute(descriptor, name); if (attribute == null) { throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND", - sm.getString("manager2.configAttributeNotFound", name)); + Strings.sm().getString("manager2.configAttributeNotFound", name)); } if (!attribute.isWriteable() || !EDITABLE_TYPES.contains(attribute.getType())) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", - sm.getString("manager2.configReadOnly", name)); + Strings.sm().getString("manager2.configReadOnly", name)); } if (RISKY_ATTRIBUTES.contains(name)) { @@ -2683,16 +2645,16 @@ private void updateAttribute(HttpServletResponse response, Map b String expected = displayName(ref.component, ref.type); if (!expected.equals(confirm)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", - sm.getString("manager2.configConfirmRequired", expected)); + Strings.sm().getString("manager2.configConfirmRequired", expected)); } } Object value = convert(attribute.getType(), body.get("value")); setValue(ref.component, attribute, value); - log(sm.getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + log(Strings.sm().getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), String.valueOf(value))); - Api.ok(response, sm.getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); + Api.ok(response, Strings.sm().getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); } @@ -2716,7 +2678,7 @@ private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, String expected = displayName(component, ref.type); if (!expected.equals(confirm)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", - sm.getString("manager2.configConfirmRequired", expected)); + Strings.sm().getString("manager2.configConfirmRequired", expected)); } } @@ -2729,15 +2691,15 @@ private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, // consumes. if (!namingEntry) { throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND", - sm.getString("manager2.configAttributeNotFound", name)); + Strings.sm().getString("manager2.configAttributeNotFound", name)); } param = true; Object current = entry.getProperty(name); oldParamValue = current == null ? null : String.valueOf(current); - attribute = new ExplicitAttribute(name, "java.lang.String", true, null, true); + attribute = new ExplicitAttribute(name, "java.lang.String", true, true); } else if (!attribute.isWritable()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", - sm.getString("manager2.configReadOnly", name)); + Strings.sm().getString("manager2.configReadOnly", name)); } Object value; @@ -2781,10 +2743,10 @@ private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, entry.setName(oldEntryName); rebindEntry(ref, oldEntryName); } catch (Exception rollbackError) { - log(sm.getString("manager2.error.config"), rollbackError); + log(Strings.sm().getString("manager2.error.config"), rollbackError); } throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", - sm.getString("manager2.configSetFailed", name, rootMessage(e))); + Strings.sm().getString("manager2.configSetFailed", name, rootMessage(e))); } } @@ -2808,14 +2770,14 @@ private void updateExplicitAttribute(HttpServletResponse response, NodeRef ref, endpointOf(connector).reloadSslHostConfig(sslHostConfig.getHostName()); } catch (Exception e) { setExplicitValue(ref.component, attribute, oldValue); - throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", sm.getString( + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", Strings.sm().getString( "manager2.configSslReloadFailed", name, displayName(ref.component, ref.type), rootMessage(e))); } } - log(sm.getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + log(Strings.sm().getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), String.valueOf(value))); - Api.ok(response, sm.getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); + Api.ok(response, Strings.sm().getString("manager2.configAttributeUpdated", name, displayName(ref.component, ref.type))); } @@ -2847,7 +2809,7 @@ private void rebindEntry(NodeRef ref, String registeredName) throws Exception { // The add is a silent no-op when the (new) name is already used // by another entry; detect that so the caller can roll back. if (findNamingEntry(namingResources, ref.type, newName) != entry) { - throw new IllegalStateException(sm.getString("manager2.configJndiNameTaken", newName)); + throw new IllegalStateException(Strings.sm().getString("manager2.configJndiNameTaken", newName)); } } @@ -2900,7 +2862,7 @@ private static void addNamingEntryTo(NamingResourcesImpl namingResources, String * Convert a JSON value to the Java type of the attribute. */ private static Object convert(String type, Object json) throws ConfigException { - String message = sm.getString("manager2.configInvalidValue", type); + String message = Strings.sm().getString("manager2.configInvalidValue", type); try { switch (type) { case "boolean": @@ -2951,15 +2913,15 @@ private static void setValue(Object component, AttributeInfo attribute, Object v m.invoke(component, value); } catch (NoSuchMethodException e) { throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", - sm.getString("manager2.configNoSetter", method)); + Strings.sm().getString("manager2.configNoSetter", method)); } catch (InvocationTargetException e) { Throwable t = e.getTargetException(); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", - sm.getString("manager2.configSetFailed", attribute.getName(), + Strings.sm().getString("manager2.configSetFailed", attribute.getName(), t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage())); } catch (IllegalAccessException e) { throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "NO_SETTER", - sm.getString("manager2.configNoSetter", method)); + Strings.sm().getString("manager2.configNoSetter", method)); } } @@ -3008,15 +2970,15 @@ private void addChecked(String label, Object component, Runnable add, Runnable u try { add.run(); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); rollback(label, undo); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", label, rootMessage(e))); + Strings.sm().getString("manager2.configStartFailed", label, rootMessage(e))); } if (component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { rollback(label, undo); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", label, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", label, "the component did not start")); } } @@ -3025,7 +2987,7 @@ private void rollback(String label, Runnable undo) { try { undo.run(); } catch (Exception e) { - log(sm.getString("manager2.configRollbackFailed", label, + log(Strings.sm().getString("manager2.configRollbackFailed", label, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()), e); } } @@ -3050,7 +3012,7 @@ private void addChild(HttpServletResponse response, Map body) th String type = string(body.get("type")); if (parentId == null || type == null) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", - sm.getString("manager2.configInvalidId")); + Strings.sm().getString("manager2.configInvalidId")); } NodeRef parent = resolve(parentId); @@ -3100,7 +3062,7 @@ private void addChild(HttpServletResponse response, Map body) th case "transport" -> addClusterTransport(response, parent, body); case "clusterListener" -> addClusterListener(response, parent, body); default -> throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UNSUPPORTED_TYPE", - sm.getString("manager2.configTypeUnsupported", type)); + Strings.sm().getString("manager2.configTypeUnsupported", type)); } } @@ -3113,14 +3075,14 @@ private void addAlias(HttpServletResponse response, NodeRef parent, Map serverComponent.addService(service), () -> serverComponent.removeService(service)); - log(sm.getString("manager2.configAuditAdd", "service", name)); - Api.ok(response, sm.getString("manager2.configAdded", name)); + log(Strings.sm().getString("manager2.configAuditAdd", "service", name)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", name)); } @@ -3173,11 +3135,11 @@ private void addHost(HttpServletResponse response, NodeRef parent, Map engine.addChild(host), () -> engine.removeChild(host)); - log(sm.getString("manager2.configAuditAdd", "host", name)); - Api.ok(response, sm.getString("manager2.configAdded", name)); + log(Strings.sm().getString("manager2.configAuditAdd", "host", name)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", name)); } @@ -3207,7 +3169,7 @@ private void addContext(HttpServletResponse response, NodeRef parent, Map host.addChild(context), () -> host.removeChild(context)); - log(sm.getString("manager2.configAuditAdd", "context", path)); - Api.ok(response, sm.getString("manager2.configAdded", path)); + log(Strings.sm().getString("manager2.configAuditAdd", "context", path)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", path)); } @@ -3265,7 +3227,7 @@ private void addWrapper(HttpServletResponse response, NodeRef parent, Map pipeline.addValve(valve), () -> pipeline.removeValve(valve)); - log(sm.getString("manager2.configAuditAdd", "valve", className)); - Api.ok(response, sm.getString("manager2.configAdded", className)); + log(Strings.sm().getString("manager2.configAuditAdd", "valve", className)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); } @@ -3331,7 +3293,7 @@ private void addListener(HttpServletResponse response, NodeRef parent, Map T newClusterComponent(String className, String defaultClassName, Cla .cast(Class.forName(name, true, server.getClass().getClassLoader()).getConstructor().newInstance()); } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", - sm.getString("manager2.configInvalidClass", name)); + Strings.sm().getString("manager2.configInvalidClass", name)); } } @@ -3391,8 +3353,8 @@ private void addCluster(HttpServletResponse response, NodeRef parent, Map container.setCluster(cluster), () -> container.setCluster(null)); - log(sm.getString("manager2.configAuditAdd", "cluster", label)); - Api.ok(response, sm.getString("manager2.configAdded", label)); + log(Strings.sm().getString("manager2.configAuditAdd", "cluster", label)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", label)); } @@ -3407,7 +3369,7 @@ private void addClusterValve(HttpServletResponse response, NodeRef parent, Map= MAX_NESTED_REALM_LEVELS) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configMaxNestedRealms")); + Strings.sm().getString("manager2.configMaxNestedRealms")); } boolean running = combined.getState().isAvailable(); try { @@ -3754,7 +3716,7 @@ private void addSubRealm(HttpServletResponse response, CombinedRealm combined, R lifecycle.start(); } } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); combined.removeRealm(realm); if (realm instanceof Lifecycle lifecycle && lifecycle.getState().isAvailable()) { try { @@ -3764,10 +3726,10 @@ private void addSubRealm(HttpServletResponse response, CombinedRealm combined, R } } throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } - log(sm.getString("manager2.configAuditAdd", "realm", className)); - Api.ok(response, sm.getString("manager2.configAdded", className)); + log(Strings.sm().getString("manager2.configAuditAdd", "realm", className)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); } @@ -3814,7 +3776,7 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M String className = string(body.get("className")); if (className == null || className.isEmpty()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.configInvalidName", "className")); + Strings.sm().getString("manager2.configInvalidName", "className")); } Object component; try { @@ -3830,7 +3792,7 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M } } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", - sm.getString("manager2.configInvalidClass", className)); + Strings.sm().getString("manager2.configInvalidClass", className)); } boolean running = context.getState().isAvailable(); @@ -3843,9 +3805,9 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M // the new one. context.setManager((Manager) component); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } // A manager that does not start on a running context is // not useful; roll the change back. On a stopped context @@ -3853,7 +3815,7 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { context.setManager(oldManager); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", className, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", className, "the component did not start")); } } case "loader" -> { @@ -3864,14 +3826,14 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M // the new one. context.setLoader((Loader) component); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { context.setLoader(oldLoader); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", className, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", className, "the component did not start")); } } case "resources" -> { @@ -3880,16 +3842,16 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M // application cannot be swapped out from under it. if (running) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONTEXT_RUNNING", - sm.getString("manager2.configContextMustBeStopped", displayName(context, "context"))); + Strings.sm().getString("manager2.configContextMustBeStopped", displayName(context, "context"))); } try { // setResources wires the resources to the context. // Their lifecycle is driven by the context (start). context.setResources((WebResourceRoot) component); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } } default -> { @@ -3898,14 +3860,14 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M try { context.setCookieProcessor((CookieProcessor) component); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } } } - log(sm.getString("manager2.configAuditAdd", type, className)); - Api.ok(response, sm.getString("manager2.configAdded", className)); + log(Strings.sm().getString("manager2.configAuditAdd", type, className)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); } @@ -3922,7 +3884,7 @@ private void addSessionIdGenerator(HttpServletResponse response, NodeRef parent, String className = string(body.get("className")); if (className == null || className.isEmpty()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.configInvalidName", "className")); + Strings.sm().getString("manager2.configInvalidName", "className")); } SessionIdGenerator generator; try { @@ -3932,7 +3894,7 @@ private void addSessionIdGenerator(HttpServletResponse response, NodeRef parent, .getConstructor().newInstance(); } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", - sm.getString("manager2.configInvalidClass", className)); + Strings.sm().getString("manager2.configInvalidClass", className)); } // The Manager interface does not extend Lifecycle; all standard @@ -3954,7 +3916,7 @@ private void addSessionIdGenerator(HttpServletResponse response, NodeRef parent, lifecycle.start(); } } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); // Best effort rollback: stop the new generator (if it // started) and restore the previous one (the setter rejects // null, so "none" cannot be restored). @@ -3972,10 +3934,10 @@ private void addSessionIdGenerator(HttpServletResponse response, NodeRef parent, // Best effort; the failure is already logged. } throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", className, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); } - log(sm.getString("manager2.configAuditAdd", "sessionIdGenerator", className)); - Api.ok(response, sm.getString("manager2.configAdded", className)); + log(Strings.sm().getString("manager2.configAuditAdd", "sessionIdGenerator", className)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); } @@ -4003,7 +3965,7 @@ private void addNamingEntry(HttpServletResponse response, NodeRef parent, Map 65535) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configInvalidValue", "port")); + Strings.sm().getString("manager2.configInvalidValue", "port")); } Connector connector = new Connector(protocol); connector.setPort(port); addChecked(connectorLabel(connector), connector, () -> service.addConnector(connector), () -> service.removeConnector(connector)); - log(sm.getString("manager2.configAuditAdd", "connector", protocol + " (port " + port + ")")); - Api.ok(response, sm.getString("manager2.configAdded", protocol + " (port " + port + ")")); + log(Strings.sm().getString("manager2.configAuditAdd", "connector", protocol + " (port " + port + ")")); + Api.ok(response, Strings.sm().getString("manager2.configAdded", protocol + " (port " + port + ")")); } @@ -4291,11 +4253,11 @@ private void addExecutor(HttpServletResponse response, NodeRef parent, Map executor.getMaxThreads()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configExecutorInvalid")); + Strings.sm().getString("manager2.configExecutorInvalid")); } addChecked(name, executor, () -> service.addExecutor(executor), () -> service.removeExecutor(executor)); - log(sm.getString("manager2.configAuditAdd", "executor", name)); - Api.ok(response, sm.getString("manager2.configAdded", name)); + log(Strings.sm().getString("manager2.configAuditAdd", "executor", name)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", name)); } @@ -4315,7 +4277,7 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< AbstractHttp11Protocol http11 = sslProtocolHandler(connector); if (http11 == null) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", - sm.getString("manager2.configSslUnsupported", connector.getProtocolHandlerClassName())); + Strings.sm().getString("manager2.configSslUnsupported", connector.getProtocolHandlerClassName())); } if (isSelfConnector(connector)) { throw self(); @@ -4328,7 +4290,7 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< } if (!SAFE_NAME.matcher(hostName).matches()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.configInvalidName", hostName)); + Strings.sm().getString("manager2.configInvalidName", hostName)); } String hostNameLower = hostName.toLowerCase(Locale.ENGLISH); SSLHostConfig[] existing = connector.findSslHostConfigs(); @@ -4351,7 +4313,7 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< boolean certificateProvided = body.get("certificate") instanceof Map; if (wasRunning && !wasSslEnabled && !certificateProvided) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configSslCertificateRequired")); + Strings.sm().getString("manager2.configSslCertificateRequired")); } if (certificateProvided) { SSLHostConfigCertificate certificate = buildCertificate(sslHostConfig, @@ -4360,7 +4322,7 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< sslHostConfig.addCertificate(certificate); } catch (IllegalArgumentException e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configInvalidValue", "certificate")); + Strings.sm().getString("manager2.configInvalidValue", "certificate")); } } @@ -4368,7 +4330,7 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< connector.addSslHostConfig(sslHostConfig); } catch (IllegalArgumentException e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); } if (wasRunning && !wasSslEnabled) { @@ -4388,15 +4350,15 @@ private void addSslHostConfig(HttpServletResponse response, NodeRef parent, Map< try { endpointOf(connector).removeSslHostConfig(hostNameLower); } catch (Exception e2) { - log(sm.getString("manager2.configRollbackFailed", hostNameLower, rootMessage(e2)), e2); + log(Strings.sm().getString("manager2.configRollbackFailed", hostNameLower, rootMessage(e2)), e2); } throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", - sm.getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); + Strings.sm().getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); } } - log(sm.getString("manager2.configAuditAdd", "sslHostConfig", hostNameLower)); - Api.ok(response, sm.getString("manager2.configAdded", hostNameLower)); + log(Strings.sm().getString("manager2.configAuditAdd", "sslHostConfig", hostNameLower)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", hostNameLower)); } @@ -4414,7 +4376,7 @@ private void addUpgradeProtocol(HttpServletResponse response, NodeRef parent, Ma } if (http11ProtocolHandler(connector) == null) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", - sm.getString("manager2.configUpgradeUnsupported", connector.getProtocolHandlerClassName())); + Strings.sm().getString("manager2.configUpgradeUnsupported", connector.getProtocolHandlerClassName())); } String className = string(body.get("className")); if (className == null || className.isEmpty()) { @@ -4429,7 +4391,7 @@ private void addUpgradeProtocol(HttpServletResponse response, NodeRef parent, Ma .getConstructor().newInstance(); } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", - sm.getString("manager2.configInvalidClass", className)); + Strings.sm().getString("manager2.configInvalidClass", className)); } // The protocol handler keeps the protocols in a plain list without // checking for duplicates; a second protocol with the same name would @@ -4442,8 +4404,8 @@ private void addUpgradeProtocol(HttpServletResponse response, NodeRef parent, Ma } } connector.addUpgradeProtocol(upgradeProtocol); - log(sm.getString("manager2.configAuditAdd", "upgradeProtocol", label)); - Api.ok(response, sm.getString("manager2.configUpgradeProtocolAdded", label)); + log(Strings.sm().getString("manager2.configAuditAdd", "upgradeProtocol", label)); + Api.ok(response, Strings.sm().getString("manager2.configUpgradeProtocolAdded", label)); } @@ -4464,7 +4426,7 @@ private void addCertificate(HttpServletResponse response, NodeRef parent, Map target.getClass().getMethod("set" + capitalize(name), String.class).invoke(target, s); } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_VALUE", - sm.getString("manager2.configInvalidValue", name)); + Strings.sm().getString("manager2.configInvalidValue", name)); } } @@ -4606,9 +4568,9 @@ private Connector connectorOfSsl(SSLHostConfig sslHostConfig) { m.setAccessible(true); return (AbstractEndpoint) m.invoke(connector.getProtocolHandler()); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SERVER_UNAVAILABLE", - sm.getString("manager2.configServerUnavailable")); + Strings.sm().getString("manager2.configServerUnavailable")); } } @@ -4629,7 +4591,7 @@ private void removeChild(HttpServletResponse response, Map body) String id = string(body.get("id")); if (id == null) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", - sm.getString("manager2.configInvalidId")); + Strings.sm().getString("manager2.configInvalidId")); } NodeRef ref = resolve(id); @@ -4649,7 +4611,7 @@ private void removeChild(HttpServletResponse response, Map body) // structural invariant is the more informative answer. if (ref.type.equals("service") && server.findServices().length <= 1) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "LAST_SERVICE", - sm.getString("manager2.configLastService")); + Strings.sm().getString("manager2.configLastService")); } if (ref.type.equals("service") && containsSelf((StandardService) ref.component)) { throw self(); @@ -4661,20 +4623,20 @@ private void removeChild(HttpServletResponse response, Map body) if (ref.type.equals("valve") && isBasicValve(ref)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BASIC_COMPONENT", - sm.getString("manager2.configBasicValve")); + Strings.sm().getString("manager2.configBasicValve")); } // The sub components a context holds exactly one of (and the // manager's session id generator) are required: they cannot be // removed, only replaced. if (Set.of("manager", "resources", "loader", "cookieProcessor", "sessionIdGenerator").contains(ref.type)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REQUIRED_COMPONENT", - sm.getString("manager2.configRequiredComponent", ref.type)); + Strings.sm().getString("manager2.configRequiredComponent", ref.type)); } // The JNDI naming resources of the server and of a context are // always present and required: they cannot be removed. if (ref.type.equals("namingResources")) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REQUIRED_COMPONENT", - sm.getString("manager2.configRequiredNamingResources")); + Strings.sm().getString("manager2.configRequiredNamingResources")); } // Cluster valves and channel interceptors are repeatable sub // components that have no removal API on a running cluster; only @@ -4682,20 +4644,20 @@ private void removeChild(HttpServletResponse response, Map body) // detached. if (ref.type.equals("clusterValve") || ref.type.equals("interceptor")) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_NOT_SUPPORTED", - sm.getString("manager2.configRemoveNotSupported", ref.type)); + Strings.sm().getString("manager2.configRemoveNotSupported", ref.type)); } // A service holds exactly one engine (its container); it cannot be // removed while it still contains hosts. if (ref.type.equals("engine") && ref.component instanceof Engine engine && countHosts(engine) > 0) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "NOT_EMPTY", - sm.getString("manager2.configNotEmpty", "engine")); + Strings.sm().getString("manager2.configNotEmpty", "engine")); } // A directly attached realm can only be removed when the container // falls back to a parent realm afterwards; otherwise the container // (and everything below it) would have no realm at all. if (ref.type.equals("realm") && ref.parent instanceof Container container && fallbackRealm(container) == null) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "LAST_REALM", - sm.getString("manager2.configLastRealm")); + Strings.sm().getString("manager2.configLastRealm")); } String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); @@ -4705,7 +4667,7 @@ private void removeChild(HttpServletResponse response, Map body) String confirm = string(body.get("confirm")); if (!label.equals(confirm)) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "CONFIRM_REQUIRED", - sm.getString("manager2.configConfirmRequired", label)); + Strings.sm().getString("manager2.configConfirmRequired", label)); } } @@ -4770,12 +4732,12 @@ private void removeChild(HttpServletResponse response, Map body) throw e; } catch (Exception e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", - sm.getString("manager2.configRemoveFailed", + Strings.sm().getString("manager2.configRemoveFailed", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); } - log(sm.getString("manager2.configAuditRemove", ref.type, label)); - Api.ok(response, sm.getString("manager2.configRemoved", label)); + log(Strings.sm().getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, Strings.sm().getString("manager2.configRemoved", label)); } @@ -4846,7 +4808,7 @@ private void removeSslHostConfig(HttpServletResponse response, NodeRef ref, Stri // removed from a running, TLS enabled connector while other // configurations remain. throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SSL_DEFAULT", - sm.getString("manager2.configSslDefault")); + Strings.sm().getString("manager2.configSslDefault")); } boolean disablesSsl = sslEnabled && remaining == 0; @@ -4861,15 +4823,15 @@ private void removeSslHostConfig(HttpServletResponse response, NodeRef ref, Stri endpointOf(connector).removeSslHostConfig(sslHostConfig.getHostName()); } catch (IllegalArgumentException e) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", - sm.getString("manager2.configRemoveFailed", rootMessage(e))); + Strings.sm().getString("manager2.configRemoveFailed", rootMessage(e))); } // When the removal disabled TLS no further action is needed: the // endpoint decides per accepted connection whether it is TLS, so // new connections are served over plain HTTP from this point on // (in-flight TLS connections complete normally). - log(sm.getString("manager2.configAuditRemove", ref.type, label)); - Api.ok(response, sm.getString("manager2.configRemoved", label)); + log(Strings.sm().getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, Strings.sm().getString("manager2.configRemoved", label)); } @@ -4889,7 +4851,7 @@ private void removeCertificate(HttpServletResponse response, NodeRef ref) throws boolean live = connector != null && connector.getState().isAvailable() && isSslEnabled(connector); if (live && sslHostConfig.getCertificates().size() <= 1) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SSL_LAST_CERTIFICATE", - sm.getString("manager2.configSslLastCertificate", connectorLabel(connector))); + Strings.sm().getString("manager2.configSslLastCertificate", connectorLabel(connector))); } Set certificates = sslHostConfig.getCertificates(); @@ -4900,13 +4862,13 @@ private void removeCertificate(HttpServletResponse response, NodeRef ref) throws } catch (Exception e) { certificates.add(certificate); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "REMOVE_FAILED", - sm.getString("manager2.configRemoveFailed", rootMessage(e))); + Strings.sm().getString("manager2.configRemoveFailed", rootMessage(e))); } } String label = displayName(certificate, "certificate"); - log(sm.getString("manager2.configAuditRemove", "certificate", label)); - Api.ok(response, sm.getString("manager2.configRemoved", label)); + log(Strings.sm().getString("manager2.configAuditRemove", "certificate", label)); + Api.ok(response, Strings.sm().getString("manager2.configRemoved", label)); } @@ -4940,19 +4902,19 @@ private boolean isBasicValve(NodeRef ref) { private static ConfigException self() { return new ConfigException(HttpServletResponse.SC_FORBIDDEN, "SELF_COMPONENT", - sm.getString("manager2.configSelfComponent")); + Strings.sm().getString("manager2.configSelfComponent")); } private static ConfigException duplicate(String name) { return new ConfigException(HttpServletResponse.SC_CONFLICT, "DUPLICATE", - sm.getString("manager2.configDuplicate", name)); + Strings.sm().getString("manager2.configDuplicate", name)); } private static ConfigException badParent(String type) { return new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", - sm.getString("manager2.configParentInvalid", type)); + Strings.sm().getString("manager2.configParentInvalid", type)); } @@ -4974,21 +4936,21 @@ private void lifecycle(HttpServletResponse response, Map body) t String op = string(body.get("op")); if (id == null || op == null || op.isEmpty()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_ID", - sm.getString("manager2.configInvalidId")); + Strings.sm().getString("manager2.configInvalidId")); } boolean start = "start".equals(op); boolean stop = "stop".equals(op); boolean restart = "restart".equals(op); if (!start && !stop && !restart) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_OP", - sm.getString("manager2.configInvalidOp", op)); + Strings.sm().getString("manager2.configInvalidOp", op)); } NodeRef ref = resolve(id); if (!(ref.component instanceof Lifecycle lifecycle)) { String label = ref.aliasValue != null ? ref.aliasValue : displayName(ref.component, ref.type); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "NOT_A_LIFECYCLE", - sm.getString("manager2.configNotALifecycle", label)); + Strings.sm().getString("manager2.configNotALifecycle", label)); } if (!restart && affectsSelf(ref)) { throw self(); @@ -4997,12 +4959,12 @@ private void lifecycle(HttpServletResponse response, Map body) t if (stop) { if (!lifecycle.getState().isAvailable()) { - Api.ok(response, sm.getString("manager2.configAlreadyStopped", label)); + Api.ok(response, Strings.sm().getString("manager2.configAlreadyStopped", label)); return; } stopChecked(lifecycle, label); - log(sm.getString("manager2.configAuditLifecycle", "stopped", label)); - Api.ok(response, sm.getString("manager2.configStopped", label)); + log(Strings.sm().getString("manager2.configAuditLifecycle", "stopped", label)); + Api.ok(response, Strings.sm().getString("manager2.configStopped", label)); return; } @@ -5010,12 +4972,12 @@ private void lifecycle(HttpServletResponse response, Map body) t stopChecked(lifecycle, label); } if (start && lifecycle.getState().isAvailable()) { - Api.ok(response, sm.getString("manager2.configAlreadyRunning", label)); + Api.ok(response, Strings.sm().getString("manager2.configAlreadyRunning", label)); return; } startChecked(lifecycle, label); - log(sm.getString("manager2.configAuditLifecycle", restart ? "restarted" : "started", label)); - Api.ok(response, sm.getString(restart ? "manager2.configRestarted" : "manager2.configStarted", label)); + log(Strings.sm().getString("manager2.configAuditLifecycle", restart ? "restarted" : "started", label)); + Api.ok(response, Strings.sm().getString(restart ? "manager2.configRestarted" : "manager2.configStarted", label)); } @@ -5026,13 +4988,13 @@ private void stopChecked(Lifecycle lifecycle, String label) throws ConfigExcepti try { lifecycle.stop(); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", - sm.getString("manager2.configStopFailed", label, rootMessage(e))); + Strings.sm().getString("manager2.configStopFailed", label, rootMessage(e))); } if (lifecycle.getState().isAvailable()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", - sm.getString("manager2.configStopFailed", label, "the component did not stop")); + Strings.sm().getString("manager2.configStopFailed", label, "the component did not stop")); } } @@ -5044,13 +5006,13 @@ private void startChecked(Lifecycle lifecycle, String label) throws ConfigExcept try { lifecycle.start(); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", label, rootMessage(e))); + Strings.sm().getString("manager2.configStartFailed", label, rootMessage(e))); } if (!lifecycle.getState().isAvailable()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - sm.getString("manager2.configStartFailed", label, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", label, "the component did not start")); } } @@ -5108,9 +5070,9 @@ private void store(HttpServletResponse response) throws Exception { try (PrintWriter writer = mover.getWriter()) { storeServerPreservingContexts(storeConfig, writer); } catch (Exception e) { - log(sm.getString("manager2.error.config"), e); + log(Strings.sm().getString("manager2.error.config"), e); throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "STORE_FAILED", - sm.getString("manager2.configStoreFailed")); + Strings.sm().getString("manager2.configStoreFailed")); } mover.move(); @@ -5118,10 +5080,10 @@ private void store(HttpServletResponse response) throws Exception { after.removeAll(before); String backup = after.isEmpty() ? null : after.iterator().next(); - log(sm.getString("manager2.configAuditStore", backup)); + log(Strings.sm().getString("manager2.configAuditStore", backup)); Map payload = new LinkedHashMap<>(); payload.put("ok", Boolean.TRUE); - payload.put("message", sm.getString("manager2.configStored", "conf/server.xml", backup == null ? "-" : backup)); + payload.put("message", Strings.sm().getString("manager2.configStored", "conf/server.xml", backup == null ? "-" : backup)); payload.put("file", "conf/server.xml"); payload.put("backup", backup); Api.json(response, payload); @@ -5307,7 +5269,7 @@ private static String displayPath(URL configFile) { private void requireServer() throws ConfigException { if (server == null) { throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SERVER_UNAVAILABLE", - sm.getString("manager2.configServerUnavailable")); + Strings.sm().getString("manager2.configServerUnavailable")); } } @@ -5333,7 +5295,7 @@ private static Map readJson(HttpServletRequest request) throws I try { return new JSONParser(body).parseObject(); } catch (Exception e) { - throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", e.getMessage()), e); } } @@ -5394,7 +5356,7 @@ private static String requiredName(Object value) throws ConfigException { String name = string(value); if (name == null || name.isEmpty() || !SAFE_NAME.matcher(name).matches()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.configInvalidName", name)); + Strings.sm().getString("manager2.configInvalidName", name)); } return name; } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java index df948a6734c3..f91772abb453 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java @@ -32,7 +32,6 @@ import org.apache.juli.logging.Log; import org.apache.juli.logging.LogFactory; -import org.apache.tomcat.util.res.StringManager; /** @@ -50,10 +49,7 @@ public class CsrfFilter implements Filter { /** - * The string manager for this package. */ - protected static final StringManager sm = Strings.manager(); - private static final SecureRandom RANDOM = new SecureRandom(); private static final int TOKEN_BYTES = 16; @@ -103,8 +99,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha String provided = req.getHeader(Constants.CSRF_HEADER); if (provided == null || !constantTimeEquals(token, provided)) { - log(req, sm.getString("csrfFilter.invalid")); - Api.error(resp, HttpServletResponse.SC_FORBIDDEN, "CSRF", sm.getString("csrfFilter.invalid")); + log(req, Strings.sm().getString("csrfFilter.invalid")); + Api.error(resp, HttpServletResponse.SC_FORBIDDEN, "CSRF", Strings.sm().getString("csrfFilter.invalid")); return; } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java index 37b7d0a0af01..1ac6fad5ea88 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java @@ -66,7 +66,8 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) if (request.getUserPrincipal() != null) { String template = Html.readTemplate(getServletContext(), "/index.html"); if (template == null) { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SPA shell missing"); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + Strings.manager(request).getString("manager2.shellMissing")); return; } Html.render(request, response, template); diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java index 247f7f44bdca..61b810567678 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java @@ -47,12 +47,6 @@ public class HostsApiServlet extends HostManagerServlet { private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { @@ -78,11 +72,11 @@ public void doPost(HttpServletRequest request, HttpServletResponse response) thr Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_JSON", e.getMessage()); } } else if (path.equals("/api/hosts/persist")) { - sendResult(response, invoke(pw -> super.persist(pw, clientSm(request)))); + sendResult(response, invoke(pw -> super.persist(pw, legacySm(request)))); } else if (path.matches("/api/hosts/[^/]+/start")) { - sendResult(response, invoke(pw -> super.start(pw, nameOf(path), clientSm(request)))); + sendResult(response, invoke(pw -> super.start(pw, nameOf(path), legacySm(request)))); } else if (path.matches("/api/hosts/[^/]+/stop")) { - sendResult(response, invoke(pw -> super.stop(pw, nameOf(path), clientSm(request)))); + sendResult(response, invoke(pw -> super.stop(pw, nameOf(path), legacySm(request)))); } else { Api.notFound(response); } @@ -96,7 +90,7 @@ public void doDelete(HttpServletRequest request, HttpServletResponse response) t if (path.matches("/api/hosts/[^/]+")) { String name = nameOf(path); - sendResult(response, invoke(pw -> super.remove(pw, name, clientSm(request)))); + sendResult(response, invoke(pw -> super.remove(pw, name, legacySm(request)))); } else { Api.notFound(response); } @@ -117,7 +111,7 @@ private void handleAdd(HttpServletRequest request, HttpServletResponse response) boolean copyXML = booleanValue(body.get("copyXML"), false); sendResult(response, invoke(pw -> super.add(pw, name, aliases, appBase, manager, autoDeploy, deployOnStartup, - deployXML, unpackWARs, copyXML, clientSm(request)))); + deployXML, unpackWARs, copyXML, legacySm(request)))); } @@ -212,7 +206,7 @@ private static Map readJson(HttpServletRequest request) throws I try { return new JSONParser(body).parseObject(); } catch (Exception e) { - throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", e.getMessage()), e); } } @@ -230,10 +224,10 @@ private String invoke(Operation operation) { /** - * A StringManager for the legacy host manager message bundle. All the inherited operations report their results + * A StringManager for the legacy host-manager message bundle. All the inherited operations report their results * using the {@code org.apache.catalina.manager.host} strings. */ - private static StringManager clientSm(HttpServletRequest request) { + private static StringManager legacySm(HttpServletRequest request) { return StringManager.getManager("org.apache.catalina.manager.host", request.getLocales()); } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java index 54605d966cab..b3ac04a5d6f2 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java @@ -19,17 +19,26 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.tomcat.util.res.StringManager; + /** - * Renders the static HTML pages that are served by a servlet (the login page and the error pages) instead of by the - * default servlet. The pages are rendered from their templates so that a {@code } element can be injected. The - * base element is required because these pages can be displayed at arbitrary URLs (the browser keeps the URL of the - * request that triggered the forward to the page), which would break the relative links to the CSS. + * Renders the static HTML pages that are served by a servlet (the SPA shell, the login page and the error pages) + * instead of by the default servlet. The pages are rendered from their templates so that a {@code } element can + * be injected. The base element is required because these pages can be displayed at arbitrary URLs (the browser keeps + * the URL of the request that triggered the forward to the page), which would break the relative links to the CSS. + *

+ * Rendering also localizes the templates: {@code #{key}} tokens are replaced with the messages of the + * {@link StringManager} matching the locales sent by the client, and the {@code {{lang}}} token with the language tag + * of that {@link StringManager}'s locale. */ final class Html { @@ -40,6 +49,18 @@ final class Html { */ static final String BASE_PLACEHOLDER = ""; + /** + * Token in the HTML templates that is replaced with the localized messages identified by the key inside the + * braces, e.g. {@code #{manager2.ui.login.title}}. + */ + static final Pattern MESSAGE_TOKEN = Pattern.compile("#\\{([^{}\\s]+)\\}"); + + /** + * Token in the HTML templates that is replaced with the language tag of the locale of the response, for the + * {@code lang} attribute of the {@code } element. + */ + static final String LANG_TOKEN = "{{lang}}"; + private Html() { // Utility class @@ -67,18 +88,76 @@ static String readTemplate(ServletContext context, String path) { /** - * Render a template: inject the base element and write the result to the response. + * Render a template: localize it with the locales of the request, inject the base element and write the result to + * the response. * - * @param request the current request (used for the context path) + * @param request the current request (used for the context path and the locales) * @param response the response to write * @param template the template content - * + * * @throws IOException if writing the response fails */ static void render(HttpServletRequest request, HttpServletResponse response, String template) throws IOException { - String html = template.replace(BASE_PLACEHOLDER, ""); + render(request, response, template, Strings.manager(request)); + } + + + /** + * Render a template: localize it with the given string manager, inject the base element and write the result to + * the response. + * + * @param request the current request (used for the context path) + * @param response the response to write + * @param template the template content + * @param sm the string manager holding the localized messages + * + * @throws IOException if writing the response fails + */ + static void render(HttpServletRequest request, HttpServletResponse response, String template, + StringManager sm) throws IOException { + + String html = localize(template, sm); + html = html.replace(BASE_PLACEHOLDER, ""); response.setContentType("text/html"); response.setCharacterEncoding("UTF-8"); response.getWriter().print(html); } + + + /** + * Replace the message and language tokens of a template with localized values. + * + * @param template the template content + * @param sm the string manager holding the localized messages + * + * @return the localized template + */ + static String localize(String template, StringManager sm) { + Matcher matcher = MESSAGE_TOKEN.matcher(template); + StringBuilder result = new StringBuilder(template.length()); + while (matcher.find()) { + String message = sm.getString(matcher.group(1)); + // Unknown keys are left as-is so that they stand out in the page + String replacement = message == null ? matcher.group() : escapeHtml(message); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + return result.toString().replace(LANG_TOKEN, sm.getLocale().toLanguageTag()); + } + + + private static String escapeHtml(String value) { + StringBuilder result = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '&' -> result.append("&"); + case '<' -> result.append("<"); + case '>' -> result.append(">"); + case '"' -> result.append("""); + default -> result.append(c); + } + } + return result.toString(); + } } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/I18nServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/I18nServlet.java new file mode 100644 index 000000000000..08f4e71e6dc7 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/I18nServlet.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.apache.tomcat.util.res.StringManager; + + +/** + * Serves the client-side messages of this web application (the {@code manager2.ui.*} keys of the + * {@code LocalStrings} bundle) as JSON, localized with the locales the browser sends in its {@code Accept-Language} + * header, so that the scripts localize the interface with the same bundle as the server side. + *

+ * The endpoint is deliberately outside {@code /api/*}: it is not protected by a security constraint (the login page + * needs it too) and it is not covered by the CSRF filter. It exposes only message templates from the bundle, never + * server data, and it answers GET requests only. + */ +public class I18nServlet extends HttpServlet implements Servlet { + + + @Serial + private static final long serialVersionUID = 1L; + + + /** + * Prefix of the keys of the {@code LocalStrings} bundle that the client renders. + */ + static final String CLIENT_KEY_PREFIX = "manager2.ui."; + + + /** + * The keys to serve, sorted, read once from the base bundle. + */ + private volatile List keys; + + /** + * The rendered JSON payloads, keyed by the (internally cached) string managers. Bounded, since the string manager + * cache itself is bounded. + */ + private final Map payloadCache = Collections.synchronizedMap( + new LinkedHashMap<>(16, 0.75f, true) { + @Serial + private static final long serialVersionUID = 1L; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 16; + } + }); + + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + StringManager sm = Strings.manager(request); + String body = payloadCache.computeIfAbsent(sm, this::buildPayload); + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json; charset=" + Constants.CHARSET); + response.setCharacterEncoding(Constants.CHARSET); + response.setHeader("Cache-Control", "private, max-age=3600"); + response.setHeader("Vary", "Accept-Language"); + response.setContentLength(body.getBytes(java.nio.charset.StandardCharsets.UTF_8).length); + response.getWriter().print(body); + } + + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + } + + + private String buildPayload(StringManager sm) { + Map messages = new LinkedHashMap<>(); + for (String key : clientKeys()) { + String message = sm.getString(key); + if (message != null) { + messages.put(key, message); + } + } + Map payload = new LinkedHashMap<>(); + payload.put("locale", sm.getLocale().toLanguageTag()); + payload.put("messages", messages); + return Json.write(payload); + } + + + private List clientKeys() { + List result = keys; + if (result == null) { + result = new ArrayList<>(Strings.keys(CLIENT_KEY_PREFIX)); + result.sort(String::compareTo); + keys = result; + } + return result; + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties index 8d748707d9ac..1277af626ac0 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -117,3 +117,932 @@ manager2.configAuditAdd=Config: added {0} [{1}] manager2.configAuditRemove=Config: removed {0} [{1}] manager2.configAuditStore=Config: stored configuration to conf/server.xml (backup: {0}) manager2.configAuditLifecycle=Config: {0} component [{1}] + + +# ---------------------------------------------------------------- +# User interface strings. The manager2.ui.* keys are delivered to the +# browser by the /i18n endpoint (and substituted into the static +# HTML templates by Html.render); everything else is server-side +# only. +# ---------------------------------------------------------------- +manager2.ui.accesslog.subtitle=Access log files. The available filters (method, status, user, session ID) depend on the configured log format. +manager2.ui.api.csrfTokenMissing=CSRF token not available. Reload the page and try again. +manager2.ui.api.forbidden=Access denied (403): the signed-in account does not have the role required for this operation. +manager2.ui.api.requestFailed=Request failed with status {0} +manager2.ui.apps.attributeRemoved=Attribute removed. +manager2.ui.apps.attributes=Attributes +manager2.ui.apps.avgSessionLifetime=Avg session lifetime +manager2.ui.apps.cannotUndeploySelf=Cannot undeploy the manager itself +manager2.ui.apps.colClass=Class +manager2.ui.apps.colCreated=Created +manager2.ui.apps.colId=Id +manager2.ui.apps.colLastAccessed=Last accessed +manager2.ui.apps.colLocale=Locale +manager2.ui.apps.colMaxTime=Max time +manager2.ui.apps.colTimeout=Timeout (s) +manager2.ui.apps.colValue=Value +manager2.ui.apps.contextPath=Context path +manager2.ui.apps.contextPathHint=Defaults to the WAR file name. +manager2.ui.apps.contextPathOptional=Context path (optional) +manager2.ui.apps.deploy=Deploy +manager2.ui.apps.deployButton=Deploy application +manager2.ui.apps.deployTitle=Deploy application +manager2.ui.apps.details=Details +manager2.ui.apps.displayName=Display name +manager2.ui.apps.docBase=Doc base +manager2.ui.apps.expireIdle=Expire idle +manager2.ui.apps.expiredSessions=Expired sessions +manager2.ui.apps.idleSecondsPlaceholder=idle seconds +manager2.ui.apps.idleTimeoutNeeded=Enter an idle timeout in seconds. +manager2.ui.apps.invalidateSelected=Invalidate selected +manager2.ui.apps.invalidateSession=Invalidate session +manager2.ui.apps.invalidateSessionConfirm=Invalidate session {0}? +manager2.ui.apps.invalidateSessionTitle=Invalidate session +manager2.ui.apps.invalidateSessionsConfirm=Invalidate {0} session(s)? +manager2.ui.apps.invalidateSessionsTitle=Invalidate sessions +manager2.ui.apps.jspFiles=JSP files +manager2.ui.apps.jsps=JSPs +manager2.ui.apps.mappings=Mappings +manager2.ui.apps.maxSessionLife=Max session life +manager2.ui.apps.minutes={0} min +manager2.ui.apps.noApps=No applications deployed +manager2.ui.apps.noAttributes=No attributes +manager2.ui.apps.noSessions=No sessions +manager2.ui.apps.notFound=Application not found. +manager2.ui.apps.reloadConfirmTitle=Reload application +manager2.ui.apps.reloads=Reloads +manager2.ui.apps.removeAttributeConfirm=Remove attribute {0}? +manager2.ui.apps.removeAttributeTitle=Remove attribute +manager2.ui.apps.replaceExisting=Replace an existing deployment +manager2.ui.apps.seconds={0} s +manager2.ui.apps.selectSessions=Select sessions to invalidate. +manager2.ui.apps.selectWarFirst=Select a WAR file first. +manager2.ui.apps.servlets=Servlets +manager2.ui.apps.sessionActive=active +manager2.ui.apps.sessionProxy=proxy +manager2.ui.apps.sessionTimeout=Session timeout +manager2.ui.apps.sessionTitle=Session {0} +manager2.ui.apps.stopConfirmTitle=Stop application +manager2.ui.apps.stopOrReloadConfirm=Stop or reload {0}? +manager2.ui.apps.subtitle=Deploy, start, stop, reload and undeploy web applications. +manager2.ui.apps.tabMetrics=Metrics +manager2.ui.apps.tabOverview=Overview +manager2.ui.apps.tabServer=From server +manager2.ui.apps.tabSessions=Sessions +manager2.ui.apps.tabUpload=Upload +manager2.ui.apps.timeoutLabel=Timeout +manager2.ui.apps.undeploy=Undeploy +manager2.ui.apps.undeployConfirm=Undeploy {0}? The deployed files are kept on disk. +manager2.ui.apps.undeployConfirmTitle=Undeploy application +manager2.ui.apps.uploadFailedStatus=Upload failed with status {0} +manager2.ui.apps.uploadNetworkError=Upload failed (network error) +manager2.ui.apps.versionOptional=Version (optional) +manager2.ui.apps.warFile=WAR file +manager2.ui.apps.warLocationOptional=WAR location (optional) +manager2.ui.apps.xmlConfigOptional=XML configuration (optional) +manager2.ui.brand.name=Tomcat Manager +manager2.ui.col.actions=Actions +manager2.ui.col.aliases=Aliases +manager2.ui.col.committed=Committed +manager2.ui.col.description=Description +manager2.ui.col.errors=Errors +manager2.ui.col.groups=Groups +manager2.ui.col.host=Host +manager2.ui.col.max=Max +manager2.ui.col.members=Members +manager2.ui.col.name=Name +manager2.ui.col.password=Password +manager2.ui.col.path=Path +manager2.ui.col.requests=Requests +manager2.ui.col.roles=Roles +manager2.ui.col.sessions=Sessions +manager2.ui.col.state=State +manager2.ui.col.used=Used +manager2.ui.col.user=User +manager2.ui.col.username=User name +manager2.ui.col.version=Version +manager2.ui.common.add=Add +manager2.ui.common.all=All +manager2.ui.common.apply=Apply +manager2.ui.common.cancel=Cancel +manager2.ui.common.close=Close +manager2.ui.common.confirm=Confirm +manager2.ui.common.download=Download +manager2.ui.common.empty=(empty) +manager2.ui.common.errorTitle=Something went wrong +manager2.ui.common.loading=Loading +manager2.ui.common.moreActions=More actions +manager2.ui.common.noData=No data +manager2.ui.common.notFoundPath=Not found: {0} +manager2.ui.common.refresh=Refresh +manager2.ui.common.reload=Reload +manager2.ui.common.remove=Remove +manager2.ui.common.restart=Restart +manager2.ui.common.save=Save +manager2.ui.common.start=Start +manager2.ui.common.stop=Stop +manager2.ui.config.addChild=+ Add +manager2.ui.config.addChildTitle=Add {0} child +manager2.ui.config.addParameter=+ Add parameter +manager2.ui.config.alias=Alias +manager2.ui.config.auth=Auth +manager2.ui.config.cannotRemoveSelf=Cannot remove the component the manager is installed in +manager2.ui.config.certType=Certificate type +manager2.ui.config.change=Change +manager2.ui.config.changeMessage=Changing {0} of {1} may break routing. Continue? +manager2.ui.config.changeTitle=Change {0} +manager2.ui.config.children=Children +manager2.ui.config.className=Class name +manager2.ui.config.commaSeparated=comma, separated +manager2.ui.config.componentType=Component type +manager2.ui.config.components=Components +manager2.ui.config.docBasePlaceholder=relative to the host app base, or a .war +manager2.ui.config.factory=Factory +manager2.ui.config.factoryOptionsNote=The options of a first party factory are shown as fields; further parameters can be edited in the entry detail. +manager2.ui.config.globalJndiName=Global JNDI name +manager2.ui.config.hostName=Host name +manager2.ui.config.jndiName=JNDI name +manager2.ui.config.keyAlias=Key alias +manager2.ui.config.keystoreFile=Keystore file +manager2.ui.config.keystorePassword=Keystore password +manager2.ui.config.keystoreType=Keystore type +manager2.ui.config.link=Link +manager2.ui.config.maxThreads=Max threads +manager2.ui.config.minSpareThreads=Min spare threads +manager2.ui.config.name=Name +manager2.ui.config.noChanges=No changes to apply. +manager2.ui.config.noParameters=No parameters set. Use "+ Add parameter" to add one. +manager2.ui.config.noProperties=This component exposes no editable properties. +manager2.ui.config.notSet=not set +manager2.ui.config.paramNamePlaceholder=parameter name (e.g. url, maxTotal) +manager2.ui.config.paramNameRequired=A parameter name is required. +manager2.ui.config.paramNotSet=This parameter is not set. +manager2.ui.config.parameter=parameter +manager2.ui.config.path=Path +manager2.ui.config.port=Port +manager2.ui.config.properties=Properties +manager2.ui.config.protocol=Protocol +manager2.ui.config.reconnectFailed=Could not reconnect after the operation. The component may still be stopped - check the server status and try again. +manager2.ui.config.reconnected=Reconnected. Reloading the components. +manager2.ui.config.reconnecting=The connection was interrupted during the operation - this is expected when the component that serves this page is restarted. Reconnecting... +manager2.ui.config.removeMessage=Remove {0} from the running server? This cannot be undone without a reload. +manager2.ui.config.removeParameter=Remove this parameter +manager2.ui.config.removeTitle=Remove {0} +manager2.ui.config.replaceNote=Replaces the current {0} ({1}). +manager2.ui.config.restartHelp=Stop the component and start it again +manager2.ui.config.restartMessage=Restart {0}? +manager2.ui.config.restartMessageSelf=Restart {0}? This component serves this page: the connection is interrupted during the operation and the page reconnects when it is done. When the restarted component holds the admin sessions (the server, a service, an engine, a host or this context) you will need to sign in again. +manager2.ui.config.restartTitle=Restart {0} +manager2.ui.config.save=Save to server.xml +manager2.ui.config.selectComponent=Select a component in the tree to inspect and edit it. +manager2.ui.config.selfImpactTitle=This component serves this page: starting or stopping it would interrupt access to the manager. Use Restart instead. +manager2.ui.config.servletClass=Servlet class +manager2.ui.config.stopMessage=Stop {0}? Any in-memory state it holds (e.g. the sessions of the contexts below it) is lost. +manager2.ui.config.stopTitle=Stop {0} +manager2.ui.config.storeFiles=It will also rewrite the following context configuration files: +manager2.ui.config.storeManagerWarning=the file of the context this manager runs in is among them. Saving will restart the manager and reset your session - you will need to log in again. +manager2.ui.config.storeWarning=This will overwrite conf/server.xml with the live state (a timestamped backup is kept). +manager2.ui.config.subtitle=The live component tree of this server. Changes apply immediately; save to make them permanent. +manager2.ui.config.thisApp=this app +manager2.ui.config.toggle=Toggle +manager2.ui.config.typeHomeInterface=Type (home interface) +manager2.ui.config.typeLabel=Type +manager2.ui.config.typeLocalHome=Type (local home) +manager2.ui.config.typeLocalInterface=Local (business interface) +manager2.ui.config.typeServiceInterface=Type (service interface) +manager2.ui.config.unnamed=(unnamed) +manager2.ui.config.upgradeProtocolNote=The default class is the HTTP/2 upgrade protocol. The protocol only becomes active when the connector is restarted. +manager2.ui.config.urlPatterns=URL patterns (comma separated) +manager2.ui.config.valueLabel=Value +manager2.ui.config.valuePlaceholder=value +manager2.ui.config.warning=Warning: +manager2.ui.confirm.typeToConfirm=Type {0} to confirm +manager2.ui.dashboard.chart.heap=JVM heap +manager2.ui.dashboard.chart.network=Network +manager2.ui.dashboard.chart.requestRate=Request rate +manager2.ui.dashboard.chart.threads=Busy threads +manager2.ui.dashboard.kpi.activeSessions=Active sessions +manager2.ui.dashboard.kpi.busyThreads=Busy threads +manager2.ui.dashboard.kpi.errorsPerSecond=Errors / s +manager2.ui.dashboard.kpi.heapUsed=Heap used +manager2.ui.dashboard.kpi.requestsPerSecond=Requests / s +manager2.ui.dashboard.legend.committed=committed +manager2.ui.dashboard.legend.errorsPerSecond=errors/s +manager2.ui.dashboard.legend.receivedPerSecond=received B/s +manager2.ui.dashboard.legend.requestsPerSecond=requests/s +manager2.ui.dashboard.legend.sentPerSecond=sent B/s +manager2.ui.dashboard.legend.used=used +manager2.ui.dashboard.of=of {0} +manager2.ui.dashboard.subtitle=Charts show the last {0} of server activity. One sample every {1} s, collected in the background. +manager2.ui.dashboard.title=Dashboard +manager2.ui.diagnostics.allTypes=All types +manager2.ui.diagnostics.certificates=Certificates +manager2.ui.diagnostics.cipherSuites=Cipher suites +manager2.ui.diagnostics.colClass=Class +manager2.ui.diagnostics.jvmDescription=VM information and a thread dump of this process. +manager2.ui.diagnostics.leaksCheck=Check for leaks +manager2.ui.diagnostics.leaksChecking=Checking\u2026 this can take a while. +manager2.ui.diagnostics.leaksDescription=Check whether the deployed web applications hold any memory-leaking references (class loaders, threads or file handles). +manager2.ui.diagnostics.leaksNone=No leaks found. +manager2.ui.diagnostics.noResources=No resources found. +manager2.ui.diagnostics.noSslConnector=No SSL connector configured on this server. +manager2.ui.diagnostics.sslReloadButton=Reload SSL context +manager2.ui.diagnostics.sslReloadConfirm=Reload the SSL context? Connections in flight are interrupted. +manager2.ui.diagnostics.sslReloadConfirmButton=Reload +manager2.ui.diagnostics.sslReloadTitle=Reload SSL +manager2.ui.diagnostics.subtitle=SSL, memory leaks, JNDI resources and JVM diagnostics. +manager2.ui.diagnostics.tab.jndi=JNDI resources +manager2.ui.diagnostics.tab.jvm=JVM +manager2.ui.diagnostics.tab.leaks=Memory leaks +manager2.ui.diagnostics.tab.ssl=SSL +manager2.ui.diagnostics.threadDump=Thread dump +manager2.ui.diagnostics.tlsHostPlaceholder=TLS SNI host name (optional) +manager2.ui.diagnostics.trustedCertificates=Trusted certificates +manager2.ui.diagnostics.vmInfo=VM information +manager2.ui.error.403.heading=Access denied +manager2.ui.error.403.text=Your account does not have permission to perform this action. If you believe this is an error, contact your server administrator. +manager2.ui.error.404.heading=Not found +manager2.ui.error.404.text=The page or resource you requested does not exist. +manager2.ui.error.back=Back to dashboard +manager2.ui.hosts.add=Add host +manager2.ui.hosts.addTitle=Add virtual host +manager2.ui.hosts.aliasesLabel=Aliases (comma separated) +manager2.ui.hosts.aliasesPlaceholder=example.com, www.example.com +manager2.ui.hosts.appBase=App base +manager2.ui.hosts.autoDeploy=Auto deploy +manager2.ui.hosts.cannotRemoveSelf=Cannot remove the host the manager is installed in +manager2.ui.hosts.copyXml=Copy XML +manager2.ui.hosts.copyXmlHelp=Copy context XML from the deployed WAR into META-INF/context.xml +manager2.ui.hosts.deployOnStartup=Deploy on startup +manager2.ui.hosts.deployXml=Deploy XML +manager2.ui.hosts.empty=No virtual hosts configured +manager2.ui.hosts.managerWebapp=Manager webapp +manager2.ui.hosts.managerWebappHelp=Deploy the manager webapp to this host +manager2.ui.hosts.nameMissing=Enter a host name. +manager2.ui.hosts.namePlaceholder=localhost +manager2.ui.hosts.removeConfirm=Remove host {0}? Applications on it will be undeployed (files are kept). +manager2.ui.hosts.removeConfirmTitle=Remove host +manager2.ui.hosts.save=Save to server.xml +manager2.ui.hosts.selfColumn=Self +manager2.ui.hosts.stopConfirm=Stop host {0}? All applications on it will be stopped. +manager2.ui.hosts.stopConfirmTitle=Stop host +manager2.ui.hosts.subtitle=Add, start, stop and remove virtual hosts on this engine. +manager2.ui.hosts.thisHost=this host +manager2.ui.hosts.title=Virtual hosts +manager2.ui.hosts.unpackWars=Unpack WARs +manager2.ui.login.brand=Apache Tomcat +manager2.ui.login.errorText=Your user name or password was incorrect. +manager2.ui.login.errorTitle=Sign in failed. +manager2.ui.login.foot=Use an account with the manager-gui role for full access or manager-status for read-only monitoring. +manager2.ui.login.password=Password +manager2.ui.login.submit=Sign in +manager2.ui.login.subtitle=Manager \u2014 sign in to manage your server. +manager2.ui.login.title=Log in \u00b7 Tomcat Manager +manager2.ui.login.username=User name +manager2.ui.logs.subtitle=Server log files (JULI). Filter by severity and search the records. +manager2.ui.logviewer.allSeverities=All severities +manager2.ui.logviewer.col.connection=Connection +manager2.ui.logviewer.col.elapsed=Elapsed +manager2.ui.logviewer.col.elapsedSeconds=Elapsed (s) +manager2.ui.logviewer.col.firstByte=First byte +manager2.ui.logviewer.col.level=Level +manager2.ui.logviewer.col.localAddr=Local addr +manager2.ui.logviewer.col.message=Message +manager2.ui.logviewer.col.method=Method +manager2.ui.logviewer.col.port=Port +manager2.ui.logviewer.col.protocol=Protocol +manager2.ui.logviewer.col.query=Query +manager2.ui.logviewer.col.raw=Line +manager2.ui.logviewer.col.remoteAddr=Remote addr +manager2.ui.logviewer.col.request=Request +manager2.ui.logviewer.col.server=Server +manager2.ui.logviewer.col.session=Session +manager2.ui.logviewer.col.size=Size +manager2.ui.logviewer.col.sizeNoContentLength=Size (no C-L) +manager2.ui.logviewer.col.source=Source +manager2.ui.logviewer.col.status=Status +manager2.ui.logviewer.col.thread=Thread +manager2.ui.logviewer.col.throwable=Stack trace +manager2.ui.logviewer.col.time=Time +manager2.ui.logviewer.col.user=User +manager2.ui.logviewer.file=File +manager2.ui.logviewer.filterBySessionId=Filter by session ID +manager2.ui.logviewer.filterByUser=Filter by user +manager2.ui.logviewer.maxLines=Max lines +manager2.ui.logviewer.noAccessLogFiles=No access log files were found in the logs directory. +manager2.ui.logviewer.noFiles=No log files found +manager2.ui.logviewer.noLogFiles=No log files were found in the logs directory. +manager2.ui.logviewer.noMatches=No lines match the current filters. +manager2.ui.logviewer.recordTitle=Record +manager2.ui.logviewer.search=Search +manager2.ui.logviewer.searchPlaceholder=Search +manager2.ui.logviewer.sessionId=Session ID +manager2.ui.logviewer.severity=Severity +manager2.ui.logviewer.summaryPlural=Showing {0} of {1} matching lines (total {2}) +manager2.ui.logviewer.summarySingular=Showing {0} of {1} matching line (total {2}) +manager2.ui.logviewer.truncated=file truncated to the last {0} MiB +manager2.ui.monitoring.col.bytesIn=Bytes in +manager2.ui.monitoring.col.bytesOut=Bytes out +manager2.ui.monitoring.col.connector=Connector +manager2.ui.monitoring.col.errors=Errors +manager2.ui.monitoring.col.keepAlive=Keep-alive +manager2.ui.monitoring.col.processingTime=Processing time +manager2.ui.monitoring.col.received=Received +manager2.ui.monitoring.col.remoteAddress=Remote address +manager2.ui.monitoring.col.request=Request +manager2.ui.monitoring.col.requests=Requests +manager2.ui.monitoring.col.sent=Sent +manager2.ui.monitoring.col.stage=Stage +manager2.ui.monitoring.col.threads=Threads +manager2.ui.monitoring.col.threadsBusyCurrentMax=Threads (busy / current / max) +manager2.ui.monitoring.col.time=Time +manager2.ui.monitoring.col.virtualHost=Virtual host +manager2.ui.monitoring.connectors=Connectors +manager2.ui.monitoring.cores=Cores +manager2.ui.monitoring.cpu=CPU +manager2.ui.monitoring.daemonThreads=Daemon threads +manager2.ui.monitoring.heapCommitted=Heap committed +manager2.ui.monitoring.heapUnbounded={0} (unbounded) +manager2.ui.monitoring.jvmHeap=JVM heap +manager2.ui.monitoring.loadAverage=Load average +manager2.ui.monitoring.memory=Memory +manager2.ui.monitoring.noConnectors=No connectors found +manager2.ui.monitoring.noWorkers=No active sockets +manager2.ui.monitoring.nonHeapUsed=Non-heap used +manager2.ui.monitoring.none=none +manager2.ui.monitoring.notAvailable=Not available +manager2.ui.monitoring.peakThreads=Peak threads +manager2.ui.monitoring.physicalMemory=Physical memory +manager2.ui.monitoring.pool=Pool +manager2.ui.monitoring.processCpu=JVM process CPU +manager2.ui.monitoring.processingTime={0} (max {1}) +manager2.ui.monitoring.stage.finishing=Finishing +manager2.ui.monitoring.stage.keepAlive=Keep-alive +manager2.ui.monitoring.stage.parsing=Parsing request +manager2.ui.monitoring.stage.ready=Ready +manager2.ui.monitoring.stage.service=Service +manager2.ui.monitoring.stage.unknown=Unknown +manager2.ui.monitoring.subtitle=Instant CPU and memory snapshot, live connectors and worker (socket) table. Refreshes every 5 seconds; paused while the tab is hidden. +manager2.ui.monitoring.swap=Swap +manager2.ui.monitoring.systemCpu=System CPU +manager2.ui.monitoring.threads=Threads +manager2.ui.monitoring.threadsLive={0} live +manager2.ui.monitoring.usedOfTotal={0} / {1} +manager2.ui.monitoring.workerCount={0} active \u00b7 {1} idle +manager2.ui.monitoring.workers=Active workers +manager2.ui.nav.accessLog=Access log +manager2.ui.nav.apps=Applications +manager2.ui.nav.configuration=Configuration +manager2.ui.nav.dashboard=Dashboard +manager2.ui.nav.diagnostics=Diagnostics +manager2.ui.nav.hosts=Hosts +manager2.ui.nav.logs=Logs +manager2.ui.nav.monitoring=Monitoring +manager2.ui.nav.users=Users +manager2.ui.shell.logout=Log out +manager2.ui.shell.nav=Main navigation +manager2.ui.shell.noscript=The Tomcat Manager requires JavaScript. +manager2.ui.shell.theme.toggle=Toggle theme +manager2.ui.shell.uptime=up {0} +manager2.ui.state.running=Running +manager2.ui.state.stopped=Stopped +manager2.ui.type.alias=alias +manager2.ui.type.certificate=certificate +manager2.ui.type.channel=channel +manager2.ui.type.cluster=cluster +manager2.ui.type.clusterListener=clusterListener +manager2.ui.type.clusterManager=clusterManager +manager2.ui.type.clusterValve=clusterValve +manager2.ui.type.connector=connector +manager2.ui.type.context=context +manager2.ui.type.cookieProcessor=cookieProcessor +manager2.ui.type.deployer=deployer +manager2.ui.type.ejb=ejb +manager2.ui.type.engine=engine +manager2.ui.type.environment=environment +manager2.ui.type.executor=executor +manager2.ui.type.host=host +manager2.ui.type.interceptor=interceptor +manager2.ui.type.listener=listener +manager2.ui.type.loader=loader +manager2.ui.type.localEjb=localEjb +manager2.ui.type.manager=manager +manager2.ui.type.membership=membership +manager2.ui.type.namingResources=namingResources +manager2.ui.type.realm=realm +manager2.ui.type.receiver=receiver +manager2.ui.type.resource=resource +manager2.ui.type.resourceEnvRef=resourceEnvRef +manager2.ui.type.resourceLink=resourceLink +manager2.ui.type.resources=resources +manager2.ui.type.sender=sender +manager2.ui.type.server=server +manager2.ui.type.service=service +manager2.ui.type.serviceRef=serviceRef +manager2.ui.type.sessionIdGenerator=sessionIdGenerator +manager2.ui.type.sslHostConfig=sslHostConfig +manager2.ui.type.transport=transport +manager2.ui.type.upgradeProtocol=upgradeProtocol +manager2.ui.type.valve=valve +manager2.ui.type.wrapper=wrapper +manager2.ui.users.addGroup=Add group +manager2.ui.users.addRole=Add role +manager2.ui.users.addUser=Add user +manager2.ui.users.badgeNotWritable=not writable +manager2.ui.users.badgeReadonly=read-only +manager2.ui.users.badgeWritable=writable +manager2.ui.users.dbSelect=User database +manager2.ui.users.descriptionField=Description (optional) +manager2.ui.users.fullNameField=Full name (optional) +manager2.ui.users.groupAdded=Group added. +manager2.ui.users.groupColumn=Group +manager2.ui.users.groupNameField=Group name +manager2.ui.users.groupRemoved=Group removed. +manager2.ui.users.groupsHeading=Groups +manager2.ui.users.inheritedTitle=Inherited through group membership +manager2.ui.users.jndiId=\ (id {0}) +manager2.ui.users.jndiResource=JNDI resource {0} +manager2.ui.users.membersField=Members (comma separated) +manager2.ui.users.membersOf=Members of {0} +manager2.ui.users.membersSaved=Members saved. +manager2.ui.users.newPassword=New password +manager2.ui.users.noGroups=No groups in this database. +manager2.ui.users.noRoles=No roles defined in this database. +manager2.ui.users.noUsers=No users in this database. +manager2.ui.users.passwordFor=Password for {0} +manager2.ui.users.passwordHint=Stored with the same semantics as the password attribute of tomcat-users.xml. +manager2.ui.users.passwordSaved=Password saved. +manager2.ui.users.readonlyBanner=This user database is read-only. Add {0} to its {1} definition in {2} and restart the server to allow changes. +manager2.ui.users.removeGroupConfirm=Remove the group "{0}" and its membership from all users? +manager2.ui.users.removeGroupTitle=Remove group +manager2.ui.users.removeRoleConfirm=Remove the role "{0}"? It will be detached from all users and groups that hold it. +manager2.ui.users.removeRoleTitle=Remove role +manager2.ui.users.removeUserConfirm=Remove the user "{0}" and all of its roles and group memberships? +manager2.ui.users.removeUserTitle=Remove user +manager2.ui.users.roleAdded=Role added. +manager2.ui.users.roleColumn=Role +manager2.ui.users.roleNameField=Role name +manager2.ui.users.roleRemoved=Role removed. +manager2.ui.users.rolesField=Roles (comma separated) +manager2.ui.users.rolesFor=Roles for {0} +manager2.ui.users.rolesHeading=Roles +manager2.ui.users.rolesHint=Roles can also be created implicitly when assigned to a user or group. +manager2.ui.users.rolesSaved=Roles saved. +manager2.ui.users.userAdded=User added. +manager2.ui.users.userRemoved=User removed. + +# Server-side responses of the manager2 API endpoints. +manager2.alreadyDeployed=The application at context path [{0}] is already deployed +manager2.contextInService=The context [{0}] is in multiple services or not attached at all, and cannot be safely handled by name-only commands +manager2.deployFailed=The application was not deployed at context path [{0}] +manager2.deployed=The application has been deployed at context path [{0}] +manager2.deployedNotStarted=The application has been deployed at context path [{0}] but has not been configured to start +manager2.loginTemplateMissing=The login page template (login.html) is missing +manager2.notFound=Not found +manager2.sessionsInvalidated={0} sessions invalidated. +manager2.shellMissing=The manager application shell (index.html) is missing +manager2.uploadDeleteFailed=Failed to delete the previously deployed war file [{0}] +manager2.uploadFailed=File upload failed, cause: [{0}] +manager2.uploadNoFile=File upload failed - no file +manager2.uploadNotWar=File upload failed - [{0}] is not a war file +manager2.uploadRenameFailed=Failed to rename [{0}] to [{1}] +manager2.uploadWarExists=Deployment of the war file failed because [{0}] is already deployed +manager2.ui.apps.invalidate=Invalidate + +# ---------------------------------------------------------------- +# Descriptions of the configuration editor attributes, looked up +# dynamically by attribute name: manager2.attr.. for +# component and JNDI entry attributes, manager2.param. for the +# factory options of a JNDI resource (scoped by factory with +# manager2.param.. when the text is factory +# specific). +# ---------------------------------------------------------------- +manager2.attr.certificate.certificateChainFile=The certificate chain file (PEM, OpenSSL). +manager2.attr.certificate.certificateFile=The certificate file (PEM, OpenSSL). +manager2.attr.certificate.certificateKeyAlias=The alias of the key entry in the keystore. +manager2.attr.certificate.certificateKeyFile=The private key file (PEM, OpenSSL) +manager2.attr.certificate.certificateKeyPassword=The private key password (if different from the keystore password). +manager2.attr.certificate.certificateKeyPasswordFile=The file that contains the private key password. +manager2.attr.certificate.certificateKeystoreFile=The keystore file (JKS or PKCS12). +manager2.attr.certificate.certificateKeystorePassword=The keystore password. +manager2.attr.certificate.certificateKeystorePasswordFile=The file that contains the keystore password. +manager2.attr.certificate.certificateKeystoreProvider=The keystore provider. +manager2.attr.certificate.certificateKeystoreType=The keystore type (e.g. PKCS12). +manager2.attr.certificate.type=The certificate type (the default certificate has no type). +manager2.attr.channel.heartbeat=Whether the channel manages its own heartbeat thread. +manager2.attr.channel.heartbeatSleeptime=The interval in milliseconds between heartbeats. +manager2.attr.channel.jmxDomain=The JMX domain for the channel components. +manager2.attr.channel.jmxPrefix=The JMX name prefix for the channel components. +manager2.attr.channel.name=The name of the channel. +manager2.attr.channel.optionCheck=Whether to check that the channel is correctly configured before starting. +manager2.attr.channelInterceptor.optionFlag=The option flag that controls the behaviour of the interceptor. +manager2.attr.cookieProcessor.cookiesWithoutEquals=How to handle cookie names without an equals sign in the cookie header. +manager2.attr.cookieProcessor.partitioned=Whether the Partitioned attribute is added to the cookies of this web application. +manager2.attr.cookieProcessor.sameSiteCookies=The SameSite attribute added to the cookies of this web application (Unset, None, Lax or Strict). +manager2.attr.ejb.description=The description of the EJB reference. +manager2.attr.ejb.home=The fully qualified name of the home interface (alternative to type). +manager2.attr.ejb.link=The JNDI name of the remote EJB the reference links to. +manager2.attr.ejb.name=The JNDI name of the EJB reference. +manager2.attr.ejb.remote=The fully qualified name of the remote interface. +manager2.attr.ejb.type=The fully qualified name of the home interface. +manager2.attr.environment.description=The description of the entry. +manager2.attr.environment.name=The JNDI name of the environment entry. +manager2.attr.environment.override=Whether the context entry overrides a global entry with the same name. +manager2.attr.environment.type=The type of the entry (e.g. java.lang.String, javax.sql.DataSource). +manager2.attr.environment.value=The value of the entry. +manager2.attr.http2Protocol.allowSchemeMismatch=Whether HTTP/2 streams may provide a scheme that does not match the transport. +manager2.attr.http2Protocol.discardRequestsAndResponses=Whether requests and responses are discarded after processing instead of being recycled. +manager2.attr.http2Protocol.drainTimeout=The additional time in nanoseconds between the first and the final GOAWAY while a connection is drained. +manager2.attr.http2Protocol.initialWindowSize=The initial window size advertised to the client in bytes. +manager2.attr.http2Protocol.initiatePingDisabled=Whether the periodic PING frames that keep the connection alive are disabled. +manager2.attr.http2Protocol.keepAliveTimeout=The keep alive timeout in milliseconds. +manager2.attr.http2Protocol.maxConcurrentStreamExecution=The maximum number of concurrently executing streams per connection. +manager2.attr.http2Protocol.maxConcurrentStreams=The maximum number of concurrent streams per connection. +manager2.attr.http2Protocol.maxHeaderCount=The maximum number of headers allowed per request. +manager2.attr.http2Protocol.maxHeaderSize=The maximum size of request headers in bytes (set on the HTTP/1.1 protocol handler). +manager2.attr.http2Protocol.maxTrailerCount=The maximum number of trailer headers allowed per request. +manager2.attr.http2Protocol.maxTrailerSize=The maximum size of trailer headers in bytes (set on the HTTP/1.1 protocol handler). +manager2.attr.http2Protocol.overheadContinuationThreshold=The payload size threshold for CONTINUATION frame overhead tracking in bytes. +manager2.attr.http2Protocol.overheadCountFactor=The overhead count factor used for overhead frame tracking. +manager2.attr.http2Protocol.overheadDataThreshold=The payload size threshold for DATA frame overhead tracking in bytes. +manager2.attr.http2Protocol.overheadResetFactor=The overhead reset factor used for RST frame tracking. +manager2.attr.http2Protocol.overheadWindowUpdateThreshold=The payload size threshold for WINDOW_UPDATE frame overhead tracking in bytes. +manager2.attr.http2Protocol.readTimeout=The socket level read timeout in milliseconds. +manager2.attr.http2Protocol.streamReadTimeout=The stream level read timeout in milliseconds. +manager2.attr.http2Protocol.streamWriteTimeout=The stream level write timeout in milliseconds. +manager2.attr.http2Protocol.useSendfile=Whether to use sendfile for file transfers. +manager2.attr.http2Protocol.writeTimeout=The socket level write timeout in milliseconds. +manager2.attr.loader.delegate=Whether the web application class loader delegates to the parent class loader first. +manager2.attr.loader.jakartaConverter=The class that converts Jakarta Servlet API classes to their equivalent in the deployed application. +manager2.attr.loader.loaderClass=The class of the web application class loader instance. +manager2.attr.localEjb.description=The description of the local EJB reference. +manager2.attr.localEjb.home=The fully qualified name of the local home interface (alternative to type). +manager2.attr.localEjb.link=The JNDI name of the local EJB the reference links to. +manager2.attr.localEjb.local=The fully qualified name of the local business interface. +manager2.attr.localEjb.name=The JNDI name of the local EJB reference. +manager2.attr.localEjb.type=The fully qualified name of the local home interface. +manager2.attr.membership.address=The multicast address to join (e.g. 228.0.0.4). +manager2.attr.membership.dropTime=The time in milliseconds a member may be silent before being dropped. +manager2.attr.membership.frequency=The interval in milliseconds between membership messages. +manager2.attr.membership.localLoopbackDisabled=Whether local loopback of multicast packets is disabled. +manager2.attr.membership.port=The multicast port to join (e.g. 45564). +manager2.attr.membership.recoveryEnabled=Whether membership recovery is enabled. +manager2.attr.membership.recoverySleepTime=The sleep time in milliseconds between recovery attempts. +manager2.attr.membership.soTimeout=The socket timeout in milliseconds. +manager2.attr.membership.ttl=The time to live for multicast packets. +manager2.attr.messageDispatchInterceptor.alwaysSend=Whether to always send messages even with no other members. +manager2.attr.messageDispatchInterceptor.keepAliveTime=The keep alive time in milliseconds for idle threads. +manager2.attr.messageDispatchInterceptor.maxQueueSize=The maximum number of messages to queue. +manager2.attr.messageDispatchInterceptor.maxSpareThreads=The maximum number of idle threads to keep. +manager2.attr.messageDispatchInterceptor.maxThreads=The maximum number of threads in the dispatch pool. +manager2.attr.messageDispatchInterceptor.useDeepClone=Whether messages are deep cloned before dispatch. +manager2.attr.receiver.address=The address to bind to. +manager2.attr.receiver.autoBind=The number of attempts to auto bind an available port. +manager2.attr.receiver.maxThreads=The maximum number of listener threads. +manager2.attr.receiver.minThreads=The minimum number of listener threads. +manager2.attr.receiver.port=The TCP port to listen on. +manager2.attr.receiver.selectorTimeout=The selector timeout in milliseconds. +manager2.attr.receiver.soKeepAlive=Whether SO_KEEPALIVE is set on the sockets. +manager2.attr.receiver.soReuseAddress=Whether SO_REUSEADDR is set on the sockets. +manager2.attr.receiver.tcpNoDelay=Whether TCP_NODELAY is set on the sockets. +manager2.attr.receiver.udpPort=The UDP port to listen on. +manager2.attr.resource.auth=The JNDI authentication mode (Application or Container). +manager2.attr.resource.closeMethod=The method invoked to close the resource when it is unbound. +manager2.attr.resource.description=The description of the resource. +manager2.attr.resource.lookupName=A JNDI name to look up; when set, the other parameters are ignored. +manager2.attr.resource.name=The JNDI name of the resource (e.g. jdbc/MyDB). +manager2.attr.resource.scope=The JNDI scope of the resource (Shareable or Unshareable). +manager2.attr.resource.singleton=Whether the resource is a shared, long lived instance. +manager2.attr.resource.type=The type of the object to look up (e.g. javax.sql.DataSource). +manager2.attr.resourceEnvRef.description=The description of the resource. +manager2.attr.resourceEnvRef.name=The JNDI name of the resource. +manager2.attr.resourceEnvRef.override=Whether the context environment entry overrides a global resource with the same name. +manager2.attr.resourceEnvRef.type=The type of the object to look up. +manager2.attr.resourceLink.description=The description of the resource link. +manager2.attr.resourceLink.factory=The JNDI ObjectFactory used to resolve the global resource. +manager2.attr.resourceLink.global=The JNDI name of the (global) resource the link points to. +manager2.attr.resourceLink.name=The local JNDI name of the resource link. +manager2.attr.resourceLink.type=The type of the object the link resolves to. +manager2.attr.serviceRef.description=The description of the service reference. +manager2.attr.serviceRef.displayname=The display name of the service reference. +manager2.attr.serviceRef.interface=The fully qualified name of the service interface (alternative to type). +manager2.attr.serviceRef.name=The JNDI name of the service reference. +manager2.attr.serviceRef.type=The fully qualified name of the service interface. +manager2.attr.serviceRef.wsdlfile=The WSDL document of the service. +manager2.attr.sessionIdGenerator.jvmRoute=The jvm route appended to the generated session ids (cluster failover). +manager2.attr.sessionIdGenerator.secureRandomClass=The secure random number generator class used to create the session ids. +manager2.attr.sessionIdGenerator.sessionIdLength=The length of the generated session ids in bytes. +manager2.attr.sslHostConfig.caCertificateFile=The CA certificate file (OpenSSL). +manager2.attr.sslHostConfig.caCertificatePath=The CA certificate directory (OpenSSL). +manager2.attr.sslHostConfig.certificateRevocationListPath=The certificate revocation list directory (OpenSSL). +manager2.attr.sslHostConfig.certificateVerification=Client certificate verification: none, optional, optionalNoCA or required. +manager2.attr.sslHostConfig.certificateVerificationDepth=The depth of the client certificate chain verification. +manager2.attr.sslHostConfig.cipherSuites=The cipher suite list for TLS 1.3. +manager2.attr.sslHostConfig.ciphers=The cipher list for TLS 1.2 and below (OpenSSL or JSSE names). +manager2.attr.sslHostConfig.disableCompression=Whether TLS compression is disabled (OpenSSL). +manager2.attr.sslHostConfig.disableSessionTickets=Whether TLS session tickets are disabled (OpenSSL). +manager2.attr.sslHostConfig.groups=The enabled named groups (comma separated). +manager2.attr.sslHostConfig.honorCipherOrder=Whether to honor the server cipher order. +manager2.attr.sslHostConfig.hostName=The SNI host name this configuration applies to (lower case). +manager2.attr.sslHostConfig.insecureRenegotiation=Whether insecure renegotiation is allowed (OpenSSL) +manager2.attr.sslHostConfig.keyManagerAlgorithm=The key manager algorithm (JSSE). +manager2.attr.sslHostConfig.protocols=Enabled TLS protocols, e.g. TLSv1.2+TLSv1.3, or All. +manager2.attr.sslHostConfig.revocationEnabled=Whether CRL/OCSP revocation checking is enabled (JSSE). +manager2.attr.sslHostConfig.sessionCacheSize=The SSL session cache size. +manager2.attr.sslHostConfig.sessionTimeout=The SSL session timeout in seconds. +manager2.attr.sslHostConfig.sslProtocol=The SSL protocol (JSSE). +manager2.attr.sslHostConfig.trustManagerClassName=The trust manager class name (JSSE). +manager2.attr.sslHostConfig.truststoreAlgorithm=The truststore algorithm (JSSE). +manager2.attr.sslHostConfig.truststoreFile=The truststore file (JSSE). +manager2.attr.sslHostConfig.truststorePassword=The truststore password (JSSE). +manager2.attr.sslHostConfig.truststoreProvider=The truststore provider (JSSE). +manager2.attr.sslHostConfig.truststoreType=The truststore type (JSSE). +manager2.attr.tcpFailureDetector.connectTimeout=The timeout in milliseconds for the connection test. +manager2.attr.tcpFailureDetector.performReadTest=Whether to perform a read test. +manager2.attr.tcpFailureDetector.performSendTest=Whether to perform a send test. +manager2.attr.tcpFailureDetector.readTestTimeout=The timeout in milliseconds for the read test. +manager2.attr.tcpFailureDetector.removeSuspectsTimeout=The time in milliseconds a suspect member is kept before removal. +manager2.attr.transport.directBuffer=Whether to use direct (off heap) buffers. +manager2.attr.transport.maxRetryAttempts=The number of attempts to retransmit a message. +manager2.attr.transport.poolSize=The number of sockets in the pool (pooled senders). +manager2.attr.transport.soKeepAlive=Whether SO_KEEPALIVE is set on the sockets. +manager2.attr.transport.soReuseAddress=Whether SO_REUSEADDR is set on the sockets. +manager2.attr.transport.tcpNoDelay=Whether TCP_NODELAY is set on the sockets. +manager2.attr.transport.timeout=The socket timeout in milliseconds. +manager2.attr.transport.udpPort=The UDP port to send to. +manager2.param.abandonedUsageTracking=The stack trace tracking mode for abandoned connections. +manager2.param.accessToUnderlyingConnectionAllowed=Whether the underlying driver connection can be obtained. +manager2.param.blockWhenExhausted=Whether to block when the pool is exhausted. +manager2.param.cacheState=Whether to cache the connection state on the wrapper. +manager2.param.clearStatementPoolOnReturn=Whether the statement pool is cleared when the connection is returned. +manager2.param.connectionFactoryClassName=A custom connection factory class (instead of the driver). +manager2.param.connectionInitSqls=Semicolon separated statements executed on each new connection. +manager2.param.connectionProperties=Semicolon separated key=value pairs passed to the driver. +manager2.param.dataSourceName=The JNDI name of the DataSource to use. +manager2.param.defaultAutoCommit=The default auto commit mode of the connections. +manager2.param.defaultCatalog=The default catalog of the connections. +manager2.param.defaultMaxIdle=The default maximum number of idle connections per user. +manager2.param.defaultMaxTotal=The default maximum number of connections per user. +manager2.param.defaultMaxWaitMillis=The default maximum wait time in milliseconds per user. +manager2.param.defaultQueryTimeout=The default query timeout in seconds. +manager2.param.defaultReadOnly=The default read only mode of the connections. +manager2.param.defaultSchema=The default schema of the connections. +manager2.param.defaultTransactionIsolation=The default transaction isolation level (JDBC constant). +manager2.param.description=A description of the pool. +manager2.param.disconnectionIgnoreSqlCodes=Comma separated SQL state codes ignored during disconnection checks. +manager2.param.disconnectionSqlCodes=Comma separated SQL state codes treated as disconnections. +manager2.param.driverClassName=The JDBC driver class name. +manager2.param.enableAutoCommitOnReturn=Whether to re-enable auto commit on return. +manager2.param.evictionPolicyClassName=The class of the idle object eviction policy. +manager2.param.fastFailValidation=Whether validation fails fast on a known dead connection. +manager2.param.groupNameCol=The column name of the group name. +manager2.param.groupRoleTable=The name of the group/role mapping table. +manager2.param.groupTable=The name of the group table. +manager2.param.initialSize=The number of connections created at pool startup. +manager2.param.instanceKey=The unique key of this pool instance (defaults to the JNDI name). +manager2.param.jmxName=The JMX ObjectName under which the pool is registered. +manager2.param.lifo=Whether to allocate idle connections in LIFO order. +manager2.param.logAbandoned=Whether to log the stack trace of abandoned connections. +manager2.param.logExpiredConnections=Whether to log the expiration of pooled connections. +manager2.param.loginTimeout=The login timeout in seconds. +manager2.param.maxConnLifetimeMillis=The maximum lifetime in milliseconds of a connection. +manager2.param.maxIdle=The maximum number of idle connections in the pool. +manager2.param.maxIdlePerKey=The maximum number of idle connections per instance key. +manager2.param.maxOpenPreparedStatements=The maximum number of pooled prepared statements per connection. +manager2.param.maxTotal=The maximum number of active connections in the pool. +manager2.param.maxTotalPerKey=The maximum number of active connections per instance key. +manager2.param.maxWaitMillis=The maximum time in milliseconds to wait for a connection. +manager2.param.minEvictableIdleTimeMillis=The minimum idle time in milliseconds before a connection is evicted. +manager2.param.minIdle=The minimum number of idle connections to retain. +manager2.param.minIdlePerKey=The minimum number of idle connections per instance key. +manager2.param.numTestsPerEvictionRun=The number of connections tested per eviction run. +manager2.param.password=The JDBC connection password. +manager2.param.pathname=The path of the XML user file (default conf/tomcat-users.xml). +manager2.param.poolPreparedStatements=Whether prepared statements are pooled. +manager2.param.readonly=Whether the user database is read only. +manager2.param.registerConnectionMBean=Whether each connection is registered as an MBean. +manager2.param.removeAbandonedOnBorrow=Whether abandoned connections are removed on borrow. +manager2.param.removeAbandonedOnMaintenance=Whether abandoned connections are removed during maintenance. +manager2.param.removeAbandonedTimeout=The timeout in seconds after which a connection is considered abandoned. +manager2.param.roleAndGroupDescriptionCol=The column name of the role/group description. +manager2.param.roleNameCol=The column name of the role name. +manager2.param.roleTable=The name of the role table. +manager2.param.rollbackAfterValidation=Whether to roll back after a validation query. +manager2.param.rollbackOnReturn=Whether to roll back uncommitted transactions on return. +manager2.param.softMinEvictableIdleTimeMillis=The soft minimum idle time in milliseconds. +manager2.param.testOnBorrow=Whether to validate a connection when it is borrowed. +manager2.param.testOnCreate=Whether to validate a connection when it is created. +manager2.param.testOnReturn=Whether to validate a connection when it is returned. +manager2.param.testWhileIdle=Whether to validate connections while they are idle. +manager2.param.timeBetweenEvictionRunsMillis=The time in milliseconds between eviction runs. +manager2.param.url=The JDBC connection URL. +manager2.param.userCredCol=The column name of the user credential (password). +manager2.param.userFullNameCol=The column name of the user full name. +manager2.param.userGroupTable=The name of the user/group mapping table. +manager2.param.userNameCol=The column name of the user name. +manager2.param.userRoleTable=The name of the user/role mapping table. +manager2.param.userTable=The name of the user table. +manager2.param.username=The JDBC connection user name. +manager2.param.validationQuery=The SQL query used to validate connections. +manager2.param.validationQueryTimeout=The timeout in seconds for the validation query. +manager2.param.watchSource=Whether the user file is watched for changes and reloaded. + +# ---------------------------------------------------------------- +# Descriptions of the standard MBean descriptor attributes of the +# configuration editor (connector, engine, host, ...). They override +# the English text of the descriptors shipped with the container so +# that it can be translated. Kept in sync with the descriptors by +# TestManager2Descriptions. +# ---------------------------------------------------------------- +manager2.attr.cluster.channelSendOptions=This sets channel behaviour on sent messages. +manager2.attr.cluster.channelSendOptionsName=channelSendOptions name. +manager2.attr.cluster.channelStartOptions=This sets channel start behaviour. +manager2.attr.cluster.clusterName=name of cluster +manager2.attr.cluster.heartbeatBackgroundEnabled=enable that container background thread call channel heartbeat, default is that channel manage heartbeat itself. +manager2.attr.cluster.notifyLifecycleListenerOnFailure=notify lifecycleListener from message transfer failure +manager2.attr.connector.URIEncoding=Character encoding used to decode the URI +manager2.attr.connector.acceptCount=The accept count for this Connector +manager2.attr.connector.address=The IP address on which to bind +manager2.attr.connector.ajpFlush=Send AJP flush package for each explicit flush +manager2.attr.connector.allowTrace=Allow disabling TRACE method +manager2.attr.connector.allowedRequestAttributesPattern=Regular expression that any custom request attributes must match else the request will be rejected +manager2.attr.connector.ciphers=Comma-separated list of requested cipher suites +manager2.attr.connector.ciphersUsed=Array of ciphers suites in use +manager2.attr.connector.connectionLinger=Linger value on the incoming connection +manager2.attr.connector.connectionTimeout=Timeout value on the incoming connection +manager2.attr.connector.enableLookups=The 'enable DNS lookups' flag for this Connector +manager2.attr.connector.executorName=The name of the executor - if any - associated with this Connector +manager2.attr.connector.keepAliveTimeout=The number of milliseconds Tomcat will wait for a subsequent request before closing the connection +manager2.attr.connector.localPort=The port number on which this connector is listening to requests. If the special value for port of zero is used then this method will report the actual port bound. +manager2.attr.connector.maxHeaderCount=The maximum number of headers that are allowed by the container. 100 by default. A value of less than 0 means no limit. +manager2.attr.connector.maxKeepAliveRequests=Maximum number of Keep-Alive requests to honor per connection +manager2.attr.connector.maxParameterCount=The maximum number of parameters (GET plus POST) which will be automatically parsed by the container. 1000 by default. A value of less than 0 means no limit. +manager2.attr.connector.maxPostSize=Maximum size in bytes of a POST which will be handled by the servlet API provided features +manager2.attr.connector.maxSavePostSize=Maximum size of a POST which will be saved by the container during authentication +manager2.attr.connector.maxSwallowSize=The maximum number of request body bytes to be swallowed by Tomcat for an aborted upload +manager2.attr.connector.maxThreads=The maximum number of request processing threads to be created for the internal Executor. -1 indicates an external Executor is being used. +manager2.attr.connector.minSpareThreads=The number of request processing threads that will be created for the internal Executor. -1 indicates an external Executor is being used. +manager2.attr.connector.packetSize=The ajp packet size. +manager2.attr.connector.port=The port number (excluding any offset) on which this connector is configured to listen for requests. The special value of 0 means select a random free port when the socket is bound. +manager2.attr.connector.portOffset=The offset that will be applied to port to determine the actual port number used. +manager2.attr.connector.portWithOffset=The actual port number (including any offset) on which this connector is configured to listen for requests. +manager2.attr.connector.processorCache=The processor cache size. +manager2.attr.connector.protocol=Coyote protocol handler in use +manager2.attr.connector.proxyName=The Server name to which we should pretend requests to this Connector +manager2.attr.connector.proxyPort=The Server port to which we should pretend requests to this Connector +manager2.attr.connector.redirectPort=The redirect port (excluding any offset) for non-SSL to SSL redirects +manager2.attr.connector.redirectPortWithOffset=The actual redirect port (including any offset) for non-SSL to SSL redirects. +manager2.attr.connector.scheme=Protocol name for this Connector (http, https) +manager2.attr.connector.secretRequired=Must secret be set to a non-null, non-zero-length String? +manager2.attr.connector.secure=Is this a secure (SSL) Connector? +manager2.attr.connector.sslProtocols=Comma-separated list of SSL protocol variants to be enabled +manager2.attr.connector.tcpNoDelay=Should we use TCP no delay? +manager2.attr.connector.threadPriority=The thread priority for processors using the internal Executor. -1 indicates an external Executor is being used. +manager2.attr.connector.useBodyEncodingForURI=Should the body encoding be used for URI query parameters +manager2.attr.connector.useIPVHosts=Should IP-based virtual hosting be used? +manager2.attr.connector.xpoweredBy=Is generation of X-Powered-By response header enabled/disabled? +manager2.attr.context.altDDName=The alternate deployment descriptor name. +manager2.attr.context.antiResourceLocking=Take care to not lock resources +manager2.attr.context.baseName=The base name used for directories, WAR files (with .war appended) and context.xml files (with .xml appended). +manager2.attr.context.clearReferencesRmiTargets=Should Tomcat look for memory leaks in RMI Targets and clear them if found as a work around for application coding errors? +manager2.attr.context.clearReferencesStopThreads=Should Tomcat attempt to terminate threads that have been started by the web application? Advisable to be used only in a development environment. +manager2.attr.context.clearReferencesStopTimerThreads=Should Tomcat attempt to terminate TimerThreads that have been started by the web application? Advisable to be used only in a development environment. +manager2.attr.context.clearReferencesThreadLocals=Should Tomcat attempt to clear ThreadLocal variables that have been populated with classes loaded by the web application? +manager2.attr.context.configFile=Location of the context.xml resource or file +manager2.attr.context.configured=The correctly configured flag for this Context. +manager2.attr.context.cookies=Should we attempt to use cookies for session id communication? +manager2.attr.context.crossContext=Should we allow the ServletContext.getContext() method to access the context of other web applications in this server? +manager2.attr.context.defaultContextXml=Location of the default context.xml resource or file +manager2.attr.context.defaultWebXml=Location of the default web.xml resource or file +manager2.attr.context.displayName=The display name of this web application +manager2.attr.context.distributable=The distributable flag for this web application. +manager2.attr.context.docBase=The document root for this web application +manager2.attr.context.encodedPath=The encoded path +manager2.attr.context.errorCount=Cumulative error count of all servlets in this context +manager2.attr.context.ignoreAnnotations=Ignore annotations flag. +manager2.attr.context.logEffectiveWebXml=Should the effective web.xml be logged when the context starts? +manager2.attr.context.mapperContextRootRedirectEnabled=Should the Mapper be used for context root redirects +manager2.attr.context.mapperDirectoryRedirectEnabled=Should the Mapper be used for directory redirects +manager2.attr.context.maxTime=Maximum execution time of all servlets in this context +manager2.attr.context.minTime=Minimum execution time of all servlets in this context +manager2.attr.context.name=The name of this Context +manager2.attr.context.originalDocBase=The original document root for this web application +manager2.attr.context.override=The default context.xml override flag for this web application +manager2.attr.context.parallelAnnotationScanning=The parallel annotation scanning flag +manager2.attr.context.path=The context path for this Context +manager2.attr.context.paused=The request processing pause flag (while reloading occurs) +manager2.attr.context.privileged=Access to tomcat internals +manager2.attr.context.processingTime=Cumulative execution times of all servlets in this context +manager2.attr.context.publicId=The public identifier of the DTD for the web application deployment descriptor version that is being parsed +manager2.attr.context.reloadable=The reloadable flag for this web application +manager2.attr.context.renewThreadsWhenStoppingContext=Should Tomcat renew the threads of the thread pool when the application is stopped to avoid memory leaks because of uncleaned ThreadLocal variables. +manager2.attr.context.requestCount=Cumulative request count of all servlets in this context +manager2.attr.context.sessionCookieDomain=The domain to use for session cookies.'null' indicates that the domain is controlled by the application. +manager2.attr.context.sessionCookieName=The name to use for session cookies.'null' indicates that the name is controlled by the application. +manager2.attr.context.sessionCookiePath=The path to use for session cookies.'null' indicates that the path is controlled by the application. +manager2.attr.context.sessionTimeout=The session timeout (in minutes) for this web application +manager2.attr.context.startTime=Time (in milliseconds since January 1, 1970, 00:00:00) when this context was started +manager2.attr.context.startupTime=Time (in milliseconds) it took to start this context +manager2.attr.context.swallowOutput=Flag to set to cause the system.out and system.err to be redirected to the logger when executing a servlet +manager2.attr.context.tldScanTime=Time spend scanning jars for TLDs for this context +manager2.attr.context.tldValidation=Should the parsing of *.tld files be performed by a validating parser? +manager2.attr.context.unloadDelay=Amount of ms that the container will wait for servlets to unload +manager2.attr.context.unpackWAR=Unpack WAR property +manager2.attr.context.useHttpOnly=Indicates that session cookies should use HttpOnly +manager2.attr.context.useNaming=Create a JNDI naming context for this application? +manager2.attr.context.useRelativeRedirects=When generating location headers for 302 responses, should a relative URI be used? +manager2.attr.context.webappVersion=The version of this web application - used in parallel deployment to differentiate different versions of the same web application +manager2.attr.context.welcomeFiles=The welcome files for this context +manager2.attr.context.workDir=The pathname to the work directory for this context +manager2.attr.context.xmlNamespaceAware=Should the parsing of web.xml and web-fragment.xml files be performed by a namespace aware parser? +manager2.attr.context.xmlValidation=Should the parsing of web.xml and web-fragment.xml files be performed by a validating parser? +manager2.attr.engine.backgroundProcessorDelay=The processor delay for this component. +manager2.attr.engine.catalinaBase=Base (instance) directory for this Engine, typically same as catalina.base system property +manager2.attr.engine.defaultHost=Name of the default Host for this Engine +manager2.attr.engine.jvmRoute=Route used for load balancing +manager2.attr.engine.managedResource=The managed resource this MBean is associated with +manager2.attr.engine.modelerType=Type of the modeled resource. Can be set only once +manager2.attr.engine.name=Unique name of this Engine +manager2.attr.engine.realm=Associated realm. +manager2.attr.engine.startChildren=Will children be started automatically when they are added. +manager2.attr.engine.startStopThreads=The number of threads to use when starting and stopping child Hosts +manager2.attr.engine.stateName=The name of the LifecycleState that this component is currently in +manager2.attr.executor.activeCount=Number of threads currently processing a task +manager2.attr.executor.completedTaskCount=Number of tasks completed by the executor +manager2.attr.executor.corePoolSize=Core size of the thread pool +manager2.attr.executor.daemon=Run threads in daemon or non-daemon state? +manager2.attr.executor.largestPoolSize=Peak number of threads +manager2.attr.executor.maxIdleTime=Max number of milliseconds a thread can be idle before it can be shutdown +manager2.attr.executor.maxQueueSize=Maximum number of tasks for the pending task queue +manager2.attr.executor.maxThreads=Maximum number of allocated threads +manager2.attr.executor.minSpareThreads=Minimum number of allocated threads +manager2.attr.executor.name=Unique name of this Executor +manager2.attr.executor.namePrefix=Name prefix for thread names created by this executor +manager2.attr.executor.poolSize=Number of threads in the pool +manager2.attr.executor.queueSize=Number of tasks waiting to be processed +manager2.attr.executor.threadPriority=The thread priority for threads in this thread pool +manager2.attr.executor.threadRenewalDelay=After a context is stopped, threads in the pool are renewed. To avoid renewing all threads at the same time, this delay is observed between 2 threads being renewed. Value is in ms, default value is 1000ms. If negative, threads are not renewed. +manager2.attr.host.aliases=Host aliases +manager2.attr.host.appBase=The application root for this Host +manager2.attr.host.autoDeploy=The auto deploy flag for this Host +manager2.attr.host.backgroundProcessorDelay=The processor delay for this component. +manager2.attr.host.configClass=The configuration class for contexts +manager2.attr.host.contextClass=The Java class name of the default Context implementation class for deployed web applications. +manager2.attr.host.copyXML=Should XML files be copied to $CATALINA_BASE/conf/{engine}/{host} by default when a web application is deployed? +manager2.attr.host.createDirs=Should we create directories upon startup for appBase and xmlBase? +manager2.attr.host.deployIgnore=Paths within appBase ignored for automatic deployment +manager2.attr.host.deployOnStartup=The deploy on startup flag for this Host +manager2.attr.host.deployXML=deploy Context XML config files property +manager2.attr.host.errorReportValveClass=The Java class name of the default error reporter implementation class for deployed web applications. +manager2.attr.host.legacyAppBase=The legacy (Java EE) application root for this Host +manager2.attr.host.name=Unique name of this Host +manager2.attr.host.realm=Associated realm. +manager2.attr.host.startChildren=Will children be started automatically when they are added? +manager2.attr.host.startStopThreads=The number of threads to use when starting, stopping and deploying child Contexts +manager2.attr.host.undeployOldVersions=Determines if old versions of applications deployed using parallel deployment are automatically undeployed when no longer used. Requires autoDeploy to be enabled. +manager2.attr.host.unpackWARs=Unpack WARs property +manager2.attr.host.workDir=Work Directory base for applications +manager2.attr.host.xmlBase=The XML root for this Host. +manager2.attr.manager.activeSessions=Number of active sessions at this moment +manager2.attr.manager.expiredSessions=Number of sessions that expired ( doesn't include explicit invalidations ) +manager2.attr.manager.jvmRoute=Retrieve the JvmRoute for the enclosing Engine +manager2.attr.manager.maxActive=Maximum number of active sessions so far +manager2.attr.manager.maxActiveSessions=The maximum number of active Sessions allowed, or -1 for no limit +manager2.attr.manager.name=The descriptive name of this Manager implementation (for logging) +manager2.attr.manager.pathname=Path name of the disk file in which active sessions +manager2.attr.manager.persistAuthentication=Indicates whether sessions shall persist authentication information when being persisted (e.g. across application restarts). +manager2.attr.manager.processExpiresFrequency=The frequency of the manager checks (expiration and passivation) +manager2.attr.manager.processingTime=Time spent doing housekeeping and expiration +manager2.attr.manager.rejectedSessions=Number of sessions we rejected due to maxActive being reached +manager2.attr.manager.secureRandomAlgorithm=The secure random number generator algorithm name +manager2.attr.manager.secureRandomClass=The secure random number generator class name +manager2.attr.manager.secureRandomProvider=The secure random number generator provider name +manager2.attr.manager.sessionAttributeNameFilter=The string pattern used for including session attributes in distribution. Null means all attributes are included. +manager2.attr.manager.sessionAttributeValueClassNameFilter=The regular expression used to filter session attributes based on the implementation class of the value. The regular expression is anchored and must match the fully qualified class name. +manager2.attr.manager.sessionAverageAliveTime=Average time an expired session had been alive +manager2.attr.manager.sessionCounter=Total number of sessions created by this manager +manager2.attr.manager.sessionCreateRate=Session creation rate in sessions per minute +manager2.attr.manager.sessionExpireRate=Session expiration rate in sessions per minute +manager2.attr.manager.sessionMaxAliveTime=Longest time an expired session had been alive +manager2.attr.manager.warnOnSessionAttributeFilterFailure=Should a WARN level log message be generated if a session attribute fails to match sessionAttributeNameFilter or sessionAttributeClassNameFilter? +manager2.attr.server.address=The address on which we wait for shutdown commands. +manager2.attr.server.port=TCP port (excluding any offset) for shutdown messages +manager2.attr.server.portOffset=The offset applied to port and to the port attributes of any nested connectors +manager2.attr.server.portWithOffset=Actual TCP port (including any offset) for shutdown messages +manager2.attr.server.serverBuilt=Tomcat server built timestamp +manager2.attr.server.serverInfo=Tomcat server release identifier +manager2.attr.server.serverNumber=Tomcat server's version number +manager2.attr.server.shutdown=Shutdown password +manager2.attr.service.name=Unique name of this Service +manager2.attr.wrapper.asyncSupported=Async support +manager2.attr.wrapper.available=The date and time at which this servlet will become available (in milliseconds since the epoch), or zero if the servlet is available. If this value equals Long.MAX_VALUE, the unavailability of this servlet is considered permanent. +manager2.attr.wrapper.backgroundProcessorDelay=The processor delay for this component. +manager2.attr.wrapper.classLoadTime=Time taken to load the Servlet class +manager2.attr.wrapper.errorCount=Error count +manager2.attr.wrapper.loadOnStartup=The load-on-startup order value (negative value means load on first call) for this servlet. +manager2.attr.wrapper.loadTime=Time taken to load and initialise the Servlet +manager2.attr.wrapper.maxTime=Maximum processing time of a request +manager2.attr.wrapper.minTime=Minimum processing time of a request +manager2.attr.wrapper.processingTime=Total execution time of the servlet's service method +manager2.attr.wrapper.requestCount=Number of requests processed by this wrapper +manager2.attr.wrapper.runAs=The run-as identity for this servlet. diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocaleFilter.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocaleFilter.java new file mode 100644 index 000000000000..29094a82cd98 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocaleFilter.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.io.IOException; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + + +/** + * Binds the locales sent by the client (the {@code Accept-Language} header) to the request thread, so that the + * messages of the response - API envelopes, rendered HTML templates and log lines emitted during the request - are + * localized with the {@link Strings#sm()} manager matching those locales. Must be the first filter of the chain. + */ +public class LocaleFilter implements Filter { + + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + Strings.bindRequest((HttpServletRequest) request); + try { + chain.doFilter(request, response); + } finally { + Strings.unbindRequest(); + } + } +} diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java index 87d0d4766c46..06682d91a704 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java @@ -106,7 +106,8 @@ private void renderLogin(HttpServletRequest request, HttpServletResponse respons String template = Html.readTemplate(getServletContext(), TEMPLATE); if (template == null) { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Login page template missing"); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + Strings.manager(request).getString("manager2.loginTemplateMissing")); return; } if (request.getParameter("error") != null) { diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java index b3fbb6613c7e..d17a6e2113aa 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java @@ -43,7 +43,6 @@ import org.apache.catalina.Container; import org.apache.catalina.Valve; import org.apache.catalina.valves.AbstractAccessLogValve; -import org.apache.tomcat.util.res.StringManager; /** @@ -66,12 +65,6 @@ public class LogsApiServlet extends HttpServlet implements ContainerServlet { private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - /** * The maximum number of records returned by one request. */ @@ -175,7 +168,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) Api.notFound(response); } } catch (Exception e) { - log(sm.getString("manager2.error.logs"), e); + log(Strings.sm().getString("manager2.error.logs"), e); throw new ServletException(e); } } @@ -186,7 +179,7 @@ private void list(HttpServletResponse response, boolean access) throws IOExcepti File dir = logsDirectory(); if (dir == null) { Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", - sm.getString("manager2.logsDirMissing")); + Strings.sm().getString("manager2.logsDirMissing")); return; } @@ -578,13 +571,13 @@ private static File resolveRequestFile(HttpServletResponse response, HttpServlet File dir = logsDirectory(); if (dir == null) { Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LOGS_DIR_MISSING", - sm.getString("manager2.logsDirMissing")); + Strings.sm().getString("manager2.logsDirMissing")); return null; } String name = request.getParameter("name"); if (name == null || !SAFE_NAME.matcher(name).matches()) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.invalidLogName")); + Strings.sm().getString("manager2.invalidLogName")); return null; } File file = resolveLogFile(dir, name); diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java index 2eeb5ba92493..42771a9b30d2 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java @@ -47,7 +47,6 @@ import org.apache.catalina.Wrapper; import org.apache.catalina.util.ServerInfo; import org.apache.tomcat.util.modeler.Registry; -import org.apache.tomcat.util.res.StringManager; /** @@ -62,12 +61,6 @@ public class StatusApiServlet extends HttpServlet implements ContainerServlet, N private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - private transient MBeanServer mBeanServer = null; private final List threadPools = Collections.synchronizedList(new ArrayList<>()); @@ -127,7 +120,7 @@ public void init() throws ServletException { mBeanServer.addNotificationListener(new ObjectName("JMImplementation:type=MBeanServerDelegate"), this, null, null); } catch (Exception e) { - log(sm.getString("manager2.error.jmx"), e); + log(Strings.sm().getString("manager2.error.jmx"), e); } // Start the background collection of status samples for @@ -165,7 +158,7 @@ public void destroy() { mBeanServer.removeNotificationListener(new ObjectName("JMImplementation:type=MBeanServerDelegate"), this, null, null); } catch (Exception e) { - log(sm.getString("manager2.error.jmx"), e); + log(Strings.sm().getString("manager2.error.jmx"), e); } } @@ -239,7 +232,7 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro Api.notFound(response); } } catch (Exception e) { - log(sm.getString("manager2.error.status"), e); + log(Strings.sm().getString("manager2.error.status"), e); throw new ServletException(e); } } @@ -293,7 +286,7 @@ private void recordHistory() { try { history.record(StatusSnapshot.snapshot(mBeanServer, threadPools, host)); } catch (Exception e) { - log(sm.getString("manager2.error.status"), e); + log(Strings.sm().getString("manager2.error.status"), e); } } @@ -319,7 +312,7 @@ private long getLongInitParameter(String name, long defaultValue, long minimum) } catch (NumberFormatException e) { // Fall through to the default } - log(sm.getString("manager2.history.invalidParameter", name, value)); + log(Strings.sm().getString("manager2.history.invalidParameter", name, value)); } return defaultValue; } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java index 532b10b184cd..48dc37f95807 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java @@ -16,6 +16,16 @@ */ package org.apache.tomcat.manager2; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.ResourceBundle; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + import org.apache.tomcat.util.res.StringManager; @@ -30,10 +40,27 @@ * case the context class loader does not see the web application either and the {@link StringManager} would be created * without a bundle. To make the first creation deterministic, the context class loader is temporarily set to the class * loader of this web application. + *

+ * Messages written to a response are localized with the locales sent by the client in its {@code Accept-Language} + * header. {@link LocaleFilter} binds the {@link StringManager} matching those locales to the request thread; + * {@link #sm()} returns it, falling back to the default {@link StringManager} outside of request processing (context + * initialization, background collection). */ final class Strings { + /** + * The locales of the current request, set by {@link LocaleFilter}. + */ + private static final ThreadLocal REQUEST_LOCALES = new ThreadLocal<>(); + + + /** + * The keys of the base bundle, read lazily (see {@link #keys(String)}). + */ + private static volatile Set baseKeys; + + /** * The {@link StringManager} for the package of this web application, created in a way that always finds the * {@code LocalStrings} bundle of this web application. @@ -41,21 +68,161 @@ final class Strings { * @return the {@link StringManager} for this web application */ static StringManager manager() { + return withWebappClassLoader(() -> StringManager.getManager(Constants.Package)); + } + + + /** + * The {@link StringManager} to use for messages of the current request: the manager matching the locales the + * client sent, or the default one when there is no request bound to the thread or no bundle matches those locales. + * + * @return the {@link StringManager} for the current request or the default one + */ + static StringManager sm() { + RequestLocales requestLocales = REQUEST_LOCALES.get(); + if (requestLocales == null) { + return manager(); + } + if (requestLocales.resolved == null) { + requestLocales.resolved = resolve(requestLocales.locales); + } + return requestLocales.resolved; + } + + + /** + * The {@link StringManager} matching the locales sent by the client of the given request. + * + * @param request the current request + * + * @return the {@link StringManager} for the locales of the request + */ + static StringManager manager(HttpServletRequest request) { + List locales = new ArrayList<>(); + Enumeration requested = request.getLocales(); + while (requested.hasMoreElements()) { + locales.add(requested.nextElement()); + } + return resolve(locales); + } + + + /** + * Bind the locales sent by the client to the current thread, for the duration of the request. + * + * @param request the current request + */ + static void bindRequest(HttpServletRequest request) { + REQUEST_LOCALES.set(new RequestLocales(request.getLocales())); + } + + + /** + * Remove the request locales bound to the current thread. + */ + static void unbindRequest() { + REQUEST_LOCALES.remove(); + } + + + /** + * Resolve the best {@link StringManager} for the requested locales: the first manager whose bundle exactly + * matches a requested locale, then the first match on the language alone (so that a request for a regional + * variant uses a bundle that only carries the language), and finally the default manager. + */ + private static StringManager resolve(List requested) { + List locales = new ArrayList<>(); + for (Locale locale : requested) { + if (locale != null && !locale.getLanguage().isEmpty()) { + locales.add(locale); + } + } + for (Locale locale : locales) { + StringManager manager = manager(locale); + if (manager.getLocale().equals(locale)) { + return manager; + } + } + for (Locale locale : locales) { + Locale language = new Locale.Builder().setLanguage(locale.getLanguage()).build(); + StringManager manager = manager(language); + if (manager.getLocale().getLanguage().equals(locale.getLanguage())) { + return manager; + } + } + return manager(); + } + + + /** + * The keys of the base {@code LocalStrings} bundle of this web application that start with the given prefix. + * + * @param prefix the key prefix, or {@code null} for all keys + * + * @return the matching keys + */ + static Set keys(String prefix) { + Set all = baseKeys; + if (all == null) { + ResourceBundle bundle = ResourceBundle.getBundle(Constants.Package + ".LocalStrings", Locale.ROOT, + Strings.class.getClassLoader()); + all = Set.copyOf(bundle.keySet()); + baseKeys = all; + } + if (prefix == null) { + return all; + } + Set result = new HashSet<>(); + for (String key : all) { + if (key.startsWith(prefix)) { + result.add(key); + } + } + return result; + } + + + private static StringManager manager(Locale locale) { + return withWebappClassLoader(() -> StringManager.getManager(Constants.Package, locale)); + } + + + private static StringManager withWebappClassLoader(java.util.function.Supplier creation) { Thread thread = Thread.currentThread(); ClassLoader own = Strings.class.getClassLoader(); ClassLoader previous = thread.getContextClassLoader(); if (previous == own) { - return StringManager.getManager(Constants.Package); + return creation.get(); } thread.setContextClassLoader(own); try { - return StringManager.getManager(Constants.Package); + return creation.get(); } finally { thread.setContextClassLoader(previous); } } + /** + * The locales of a request and the lazily resolved manager for them. + */ + private static final class RequestLocales { + + private final List locales; + private StringManager resolved; + + + private RequestLocales(Enumeration requested) { + this.locales = new ArrayList<>(); + if (requested != null) { + while (requested.hasMoreElements()) { + locales.add(requested.nextElement()); + } + } + } + } + + private Strings() { // Utility class, do not instantiate } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java index 43fd3bfbf4d7..d597d1d33417 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java @@ -53,7 +53,6 @@ import org.apache.catalina.Wrapper; import org.apache.catalina.users.MemoryUserDatabase; import org.apache.tomcat.util.json.JSONParser; -import org.apache.tomcat.util.res.StringManager; /** @@ -79,12 +78,6 @@ public class UsersApiServlet extends HttpServlet implements ContainerServlet { private static final long serialVersionUID = 1L; - /** - * The string manager for this package. - */ - protected static final StringManager sm = Strings.manager(); - - /** * The JNDI type of the user database resources. */ @@ -143,7 +136,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) Api.notFound(response); } } catch (Exception e) { - log(sm.getString("manager2.error.users"), e); + log(Strings.sm().getString("manager2.error.users"), e); throw new ServletException(e); } } @@ -183,7 +176,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) Api.notFound(response); } } catch (Exception e) { - log(sm.getString("manager2.error.users"), e); + log(Strings.sm().getString("manager2.error.users"), e); throw new ServletException(e); } } @@ -207,7 +200,7 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response Api.notFound(response); } } catch (Exception e) { - log(sm.getString("manager2.error.users"), e); + log(Strings.sm().getString("manager2.error.users"), e); throw new ServletException(e); } } @@ -221,7 +214,7 @@ private void list(HttpServletResponse response, String selected) throws IOExcept List> found = discover(); if (found.isEmpty()) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_DATABASE_MISSING", - sm.getString("manager2.userDatabaseMissing")); + Strings.sm().getString("manager2.userDatabaseMissing")); return; } @@ -325,12 +318,12 @@ private void createUser(HttpServletRequest request, HttpServletResponse response String username = asString(body.get("username")); if (!validName(username)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.usernameMissing", username)); + Strings.sm().getString("manager2.usernameMissing", username)); return; } if (!body.containsKey("password") || !(body.get("password") instanceof String password)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", - sm.getString("manager2.passwordMissing")); + Strings.sm().getString("manager2.passwordMissing")); return; } List roles = asStringList(body.get("roles")); @@ -346,7 +339,7 @@ private void createUser(HttpServletRequest request, HttpServletResponse response User user = db.createUser(username, password, asString(body.get("fullName"))); if (user == null) { Api.error(response, HttpServletResponse.SC_CONFLICT, "USER_EXISTS", - sm.getString("manager2.userExists", username)); + Strings.sm().getString("manager2.userExists", username)); return; } for (String role : roles) { @@ -366,12 +359,12 @@ private void removeUser(HttpServletRequest request, HttpServletResponse response User user = db.findUser(username); if (user == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", - sm.getString("manager2.userNotFound", username)); + Strings.sm().getString("manager2.userNotFound", username)); return; } if (request.getUserPrincipal() != null && username.equals(request.getUserPrincipal().getName())) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "SELF_REMOVAL", - sm.getString("manager2.selfRemoval")); + Strings.sm().getString("manager2.selfRemoval")); return; } if (!writableForMutation(db, response)) { @@ -388,7 +381,7 @@ private void setPassword(HttpServletRequest request, HttpServletResponse respons Map body = readJson(request); if (!body.containsKey("password") || !(body.get("password") instanceof String password)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "MISSING_FIELD", - sm.getString("manager2.passwordMissing")); + Strings.sm().getString("manager2.passwordMissing")); return; } @@ -399,7 +392,7 @@ private void setPassword(HttpServletRequest request, HttpServletResponse respons User user = db.findUser(username); if (user == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", - sm.getString("manager2.userNotFound", username)); + Strings.sm().getString("manager2.userNotFound", username)); return; } if (!writableForMutation(db, response)) { @@ -423,7 +416,7 @@ private void setUserRoles(HttpServletRequest request, HttpServletResponse respon User user = db.findUser(username); if (user == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_NOT_FOUND", - sm.getString("manager2.userNotFound", username)); + Strings.sm().getString("manager2.userNotFound", username)); return; } if (!writableForMutation(db, response)) { @@ -443,7 +436,7 @@ private void createGroup(HttpServletRequest request, HttpServletResponse respons String groupname = asString(body.get("groupname")); if (!validName(groupname)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.groupnameMissing", groupname)); + Strings.sm().getString("manager2.groupnameMissing", groupname)); return; } List roles = asStringList(body.get("roles")); @@ -459,7 +452,7 @@ private void createGroup(HttpServletRequest request, HttpServletResponse respons Group group = db.createGroup(groupname, asString(body.get("description"))); if (group == null) { Api.error(response, HttpServletResponse.SC_CONFLICT, "GROUP_EXISTS", - sm.getString("manager2.groupExists", groupname)); + Strings.sm().getString("manager2.groupExists", groupname)); return; } for (String role : roles) { @@ -479,7 +472,7 @@ private void removeGroup(HttpServletRequest request, HttpServletResponse respons Group group = db.findGroup(groupname); if (group == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", - sm.getString("manager2.groupNotFound", groupname)); + Strings.sm().getString("manager2.groupNotFound", groupname)); return; } if (!writableForMutation(db, response)) { @@ -503,7 +496,7 @@ private void setGroupMembers(HttpServletRequest request, HttpServletResponse res Group group = db.findGroup(groupname); if (group == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", - sm.getString("manager2.groupNotFound", groupname)); + Strings.sm().getString("manager2.groupNotFound", groupname)); return; } @@ -515,7 +508,7 @@ private void setGroupMembers(HttpServletRequest request, HttpServletResponse res } if (!missing.isEmpty()) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UNKNOWN_GROUP_MEMBER", - sm.getString("manager2.unknownGroupMember", String.join(", ", missing))); + Strings.sm().getString("manager2.unknownGroupMember", String.join(", ", missing))); return; } if (!writableForMutation(db, response)) { @@ -548,7 +541,7 @@ private void setGroupRoles(HttpServletRequest request, HttpServletResponse respo Group group = db.findGroup(groupname); if (group == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "GROUP_NOT_FOUND", - sm.getString("manager2.groupNotFound", groupname)); + Strings.sm().getString("manager2.groupNotFound", groupname)); return; } if (!writableForMutation(db, response)) { @@ -568,7 +561,7 @@ private void createRole(HttpServletRequest request, HttpServletResponse response String rolename = asString(body.get("rolename")); if (!validName(rolename)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_NAME", - sm.getString("manager2.rolenameMissing", rolename)); + Strings.sm().getString("manager2.rolenameMissing", rolename)); return; } @@ -583,7 +576,7 @@ private void createRole(HttpServletRequest request, HttpServletResponse response Role role = db.createRole(rolename, asString(body.get("description"))); if (role == null) { Api.error(response, HttpServletResponse.SC_CONFLICT, "ROLE_EXISTS", - sm.getString("manager2.roleExists", rolename)); + Strings.sm().getString("manager2.roleExists", rolename)); return; } save(db, response); @@ -600,7 +593,7 @@ private void removeRole(HttpServletRequest request, HttpServletResponse response Role role = db.findRole(rolename); if (role == null) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "ROLE_NOT_FOUND", - sm.getString("manager2.roleNotFound", rolename)); + Strings.sm().getString("manager2.roleNotFound", rolename)); return; } // Removing a role detaches it from every user and group that holds it, @@ -609,7 +602,7 @@ private void removeRole(HttpServletRequest request, HttpServletResponse response String principalName = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null; if (principalName != null && holdsRole(db, principalName, rolename)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "SELF_ROLE_REMOVAL", - sm.getString("manager2.selfRoleRemoval", rolename)); + Strings.sm().getString("manager2.selfRoleRemoval", rolename)); return; } if (!writableForMutation(db, response)) { @@ -657,11 +650,11 @@ private List> discover() { } } } catch (NamingException e) { - log(sm.getString("manager2.userDatabaseLookup", name), e); + log(Strings.sm().getString("manager2.userDatabaseLookup", name), e); } } } catch (NamingException e) { - log(sm.getString("manager2.error.users"), e); + log(Strings.sm().getString("manager2.error.users"), e); } return found; } @@ -692,7 +685,7 @@ private Map.Entry select(String name, List bo List> found = discover(); if (found.isEmpty()) { Api.error(response, HttpServletResponse.SC_NOT_FOUND, "USER_DATABASE_MISSING", - sm.getString("manager2.userDatabaseMissing")); + Strings.sm().getString("manager2.userDatabaseMissing")); return null; } Map.Entry selected = select(name, found, response); @@ -730,12 +723,12 @@ private boolean writableForMutation(UserDatabase db, HttpServletResponse respons if (readonly(db)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_READONLY", - sm.getString("manager2.userDatabaseReadonly", db.getId())); + Strings.sm().getString("manager2.userDatabaseReadonly", db.getId())); return false; } if (!writable(db)) { Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_NOT_WRITABLE", - sm.getString("manager2.userDatabaseNotWritable", db.getId())); + Strings.sm().getString("manager2.userDatabaseNotWritable", db.getId())); return false; } return true; @@ -746,12 +739,12 @@ private void save(UserDatabase db, HttpServletResponse response) throws IOExcept try { db.save(); } catch (Exception e) { - log(sm.getString("manager2.error.users"), e); + log(Strings.sm().getString("manager2.error.users"), e); Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "USER_DATABASE_SAVE_FAILED", - sm.getString("manager2.userDatabaseSaveFailed", String.valueOf(e.getMessage()))); + Strings.sm().getString("manager2.userDatabaseSaveFailed", String.valueOf(e.getMessage()))); return; } - Api.ok(response, sm.getString("manager2.usersSaved")); + Api.ok(response, Strings.sm().getString("manager2.usersSaved")); } @@ -879,7 +872,7 @@ private static Map readJson(HttpServletRequest request) throws I try { return new JSONParser(body).parseObject(); } catch (Exception e) { - throw new IllegalArgumentException(sm.getString("manager2.invalidJson", e.getMessage()), e); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", e.getMessage()), e); } } @@ -897,13 +890,13 @@ private static List asStringList(Object value) { return new ArrayList<>(); } if (!(value instanceof List list)) { - throw new IllegalArgumentException(sm.getString("manager2.invalidJson", "expected an array of strings")); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", "expected an array of strings")); } List result = new ArrayList<>(); for (Object item : list) { if (!(item instanceof String s) || !validName(s)) { throw new IllegalArgumentException( - sm.getString("manager2.invalidJson", "expected an array of strings")); + Strings.sm().getString("manager2.invalidJson", "expected an array of strings")); } result.add(s); } diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java index 317c546a1121..9503f3fb0164 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -176,6 +176,11 @@ public void testNodeDetails() throws Exception { Assert.assertEquals("server", node.get("type")); Assert.assertNotNull(node.get("className")); Assert.assertTrue(getList(node, "properties").size() > 0); + // The descriptions come from the message bundle: the standard, + // descriptor-backed attributes of a seeded type are overridden with + // the (translatable) bundle text. + Assert.assertEquals("TCP port (excluding any offset) for shutdown messages", + propertyDescription(node, "port")); // The self context reports the self flag and its id. String ctxId = selfContextId(fetchTree(client)); @@ -184,6 +189,18 @@ public void testNodeDetails() throws Exception { Assert.assertEquals("context", node.get("type")); Assert.assertEquals(Boolean.TRUE, node.get("self")); Assert.assertTrue(((String) node.get("id")).endsWith("/context/+manager2")); + Assert.assertEquals("The display name of this web application", + propertyDescription(node, "displayName")); + + // A component without a modeler descriptor: the description of an + // explicitly defined attribute is looked up from the bundle by scope + // and name (manager2.attr.cookieProcessor.sameSiteCookies). + request(client, "GET", MANAGER2 + "/api/config/node/" + ctxId + "/cookieProcessor/0", null, null, 200); + node = parseObject(client.getResponseBody()); + Assert.assertEquals("cookieProcessor", node.get("type")); + Assert.assertEquals( + "The SameSite attribute added to the cookies of this web application (Unset, None, Lax or Strict).", + propertyDescription(node, "sameSiteCookies")); // An unknown node is reported. request(client, "GET", MANAGER2 + "/api/config/node/nowhere", null, null, 404); @@ -2230,6 +2247,18 @@ private static Map getMap(Map map, String key) { } + @SuppressWarnings("unchecked") + private static String propertyDescription(Map node, String name) { + for (Object entry : getList(node, "properties")) { + Map property = (Map) entry; + if (name.equals(property.get("name"))) { + return (String) property.get("description"); + } + } + return null; + } + + @SuppressWarnings("unchecked") private static List getList(Map map, String key) { return (List) map.get(key); diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Descriptions.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Descriptions.java new file mode 100644 index 000000000000..5f6e13d95be0 --- /dev/null +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Descriptions.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.tomcat.manager2; + +import java.util.Locale; +import java.util.Map; +import java.util.ResourceBundle; + +import org.junit.Assert; +import org.junit.Test; + +import org.apache.tomcat.util.modeler.AttributeInfo; +import org.apache.tomcat.util.modeler.ManagedBean; +import org.apache.tomcat.util.modeler.Registry; + +/** + * The {@code manager2.attr..} messages of the standard attributes (connector, engine, host, ...) + * override the English descriptions of the MBean descriptors shipped with the container so that they can be + * translated. Copying the text means it can drift when the container changes it: this test compares every seeded + * message with the live descriptor value. + */ +public class TestManager2Descriptions { + + private static final Map DESCRIPTOR_BEANS = Map.of( // + "server", "org.apache.catalina.core.StandardServer", // + "service", "org.apache.catalina.core.StandardService", // + "engine", "org.apache.catalina.core.StandardEngine", // + "host", "org.apache.catalina.core.StandardHost", // + "context", "org.apache.catalina.core.StandardContext", // + "wrapper", "org.apache.catalina.core.StandardWrapper", // + "connector", "org.apache.catalina.connector.Connector", // + "executor", "org.apache.catalina.core.StandardThreadExecutor", // + "cluster", "org.apache.catalina.ha.tcp.SimpleTcpCluster", // + "manager", "org.apache.catalina.session.StandardManager"); + + + @Test + public void seededAttributeDescriptionsMatchTheDescriptors() { + Registry registry = Registry.getRegistry(null); + ClassLoader cl = TestManager2Descriptions.class.getClassLoader(); + for (String pkg : new String[] { "org.apache.catalina.core", "org.apache.catalina.connector", + "org.apache.catalina.session", "org.apache.catalina.ha.tcp" }) { + registry.loadDescriptors(pkg, cl); + } + + ResourceBundle bundle = ResourceBundle.getBundle(Constants.Package + ".LocalStrings", Locale.ROOT, cl); + int checked = 0; + for (String key : bundle.keySet()) { + if (!key.startsWith("manager2.attr.")) { + continue; + } + String rest = key.substring("manager2.attr.".length()); + int dot = rest.indexOf('.'); + String type = rest.substring(0, dot); + String name = rest.substring(dot + 1); + String bean = DESCRIPTOR_BEANS.get(type); + if (bean == null) { + // Scope of an explicitly defined attribute table (see + // ConfigApiServlet): no descriptor to compare against. + continue; + } + ManagedBean managed = registry.findManagedBean(bean); + Assert.assertNotNull("descriptor for " + bean, managed); + AttributeInfo attribute = null; + for (AttributeInfo info : managed.getAttributes()) { + if (info.getName().equals(name)) { + attribute = info; + break; + } + } + Assert.assertNotNull(type + "." + name + " is not a descriptor attribute", attribute); + Assert.assertEquals(key, attribute.getDescription(), bundle.getString(key)); + checked++; + } + Assert.assertTrue("no seeded descriptor descriptions found", checked > 100); + } +} diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java index 64c9d400b73d..6512b47ed17a 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -157,6 +157,89 @@ public void testStaticAssetsArePublic() throws Exception { requestRaw(client, "GET", MANAGER2 + "/js/main.js", 200); + // The i18n endpoint is public too (the login page needs it). + requestRaw(client, "GET", MANAGER2 + "/i18n", 200); + + client.disconnect(); + } + + + @Test + public void testI18nEndpointServesClientMessages() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Public endpoint: it carries only the manager2.ui.* messages of the + // bundle, localized with the best bundle match for the request + // (the bundle is English only, so the base bundle always wins). + requestRaw(client, "GET", MANAGER2 + "/i18n", 200); + String body = client.getResponseBody(); + Assert.assertTrue(body.contains("\"locale\":\"en\"")); + Assert.assertTrue(body.contains("\"manager2.ui.brand.name\":\"Tomcat Manager\"")); + Assert.assertTrue(body.contains("\"manager2.ui.nav.dashboard\":\"Dashboard\"")); + // The dynamic manager2.ui.type.* keys are served as well. + Assert.assertTrue(body.contains("\"manager2.ui.type.connector\":\"connector\"")); + // Server-side-only keys are never exposed to the browser. + Assert.assertFalse(body.contains("manager2.uploadNoFile")); + Assert.assertFalse(body.contains("csrfFilter.invalid")); + boolean vary = false; + for (String header : client.getResponseHeaders()) { + if (header.toLowerCase().startsWith("vary:") && header.toLowerCase().contains("accept-language")) { + vary = true; + } + } + Assert.assertTrue("Expected a Vary: Accept-Language header", vary); + + // A locale without a bundle falls back to the base (English) bundle. + client.setRequest(new String[] { "GET " + MANAGER2 + "/i18n HTTP/1.1" + CRLF, + "Host: localhost:" + getPort() + CRLF, "Accept-Language: de-DE,de;q=0.9" + CRLF, + "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(200, client.getStatusCode()); + Assert.assertTrue(client.getResponseBody().contains("\"locale\":\"en\"")); + + // Messages only: the endpoint answers GET. + request(client, "POST", MANAGER2 + "/i18n", null, null, 405); + + client.disconnect(); + } + + + @Test + public void testLoginAndErrorPagesAreLocalizedServerSide() throws Exception { + setup(false); + + SimpleHttpClient client = new TestClient(); + client.setPort(getPort()); + client.connect(); + + // Unauthenticated SPA entry point: the login template is rendered + // with the bundle's messages, including the lang attribute, before + // any script runs. + client.setRequest(new String[] { "GET " + MANAGER2 + "/ HTTP/1.1" + CRLF, "Host: localhost:" + getPort() + CRLF, + "Connection: Close" + CRLF, CRLF }); + client.connect(); + client.processRequest(true); + Assert.assertEquals(200, client.getStatusCode()); + String login = client.getResponseBody(); + Assert.assertTrue(login.contains("j_security_check")); + Assert.assertTrue(login.contains("lang=\"en\"")); + Assert.assertTrue(login.contains("Sign in")); + Assert.assertTrue(login.contains("User name")); + Assert.assertFalse("Unsubstituted token left in the login page", login.contains("#{")); + Assert.assertFalse("Unsubstituted lang token left in the login page", login.contains("{{")); + + // 404 error page, rendered by the ErrorServlet. + requestRaw(client, "GET", MANAGER2 + "/does-not-exist", 404); + String error = client.getResponseBody(); + Assert.assertTrue(error.contains("Not found")); + Assert.assertTrue(error.contains("Back to dashboard")); + Assert.assertFalse("Unsubstituted token left in the error page", error.contains("#{")); + client.disconnect(); } diff --git a/modules/manager2/webapp/WEB-INF/web.xml b/modules/manager2/webapp/WEB-INF/web.xml index 854fa0ae7fc3..13c0e5c3c048 100644 --- a/modules/manager2/webapp/WEB-INF/web.xml +++ b/modules/manager2/webapp/WEB-INF/web.xml @@ -113,6 +113,15 @@ org.apache.tomcat.manager2.ErrorServlet + + + I18n + org.apache.tomcat.manager2.I18nServlet + + @@ -265,9 +274,24 @@ Error /error + + I18n + /i18n + + + + Locale + org.apache.tomcat.manager2.LocaleFilter + + + Locale + /* + + - + - 403 · Tomcat Manager + 403 · #{manager2.ui.brand.name}

403

-

Access denied

-

Your account does not have permission to perform this action. If you - believe this is an error, contact your server administrator.

- Back to dashboard +

#{manager2.ui.error.403.heading}

+

#{manager2.ui.error.403.text}

+ #{manager2.ui.error.back}
diff --git a/modules/manager2/webapp/error-404.html b/modules/manager2/webapp/error-404.html index aa745a679c1b..6cc28be5218c 100644 --- a/modules/manager2/webapp/error-404.html +++ b/modules/manager2/webapp/error-404.html @@ -15,20 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. --> - + - 404 · Tomcat Manager + 404 · #{manager2.ui.brand.name}

404

-

Not found

-

The page or resource you requested does not exist.

- Back to dashboard +

#{manager2.ui.error.404.heading}

+

#{manager2.ui.error.404.text}

+ #{manager2.ui.error.back}
diff --git a/modules/manager2/webapp/index.html b/modules/manager2/webapp/index.html index 238ac608700e..1870f71cd4d5 100644 --- a/modules/manager2/webapp/index.html +++ b/modules/manager2/webapp/index.html @@ -15,12 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. --> - + - Tomcat Manager + #{manager2.ui.brand.name} @@ -30,17 +30,17 @@
- Tomcat Manager + #{manager2.ui.brand.name}
- - + +
-
- + From 02a7db7e5870821c7ac9fe6b8342c1d95ef6d0d7 Mon Sep 17 00:00:00 2001 From: remm Date: Thu, 17 Sep 2026 15:50:30 +0200 Subject: [PATCH 14/17] Update for new context flag to avoid saving to server.xml --- .../tomcat/manager2/ConfigApiServlet.java | 46 +++++++++++-------- .../tomcat/manager2/TestManager2Config.java | 16 +++++-- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 54b94e2befb8..661a17661825 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -111,6 +111,7 @@ import org.apache.catalina.tribes.transport.MultiPointSender; import org.apache.catalina.tribes.transport.ReceiverBase; import org.apache.catalina.tribes.transport.ReplicationTransmitter; +import org.apache.catalina.util.ContextName; import org.apache.catalina.util.LifecycleBase; import org.apache.catalina.util.SessionIdGeneratorBase; import org.apache.coyote.AbstractProtocol; @@ -5216,25 +5217,34 @@ public Map getCaptured() { public void store(PrintWriter aWriter, int indent, Object aContext) throws Exception { if (aContext instanceof StandardContext context && getRegistry() != null) { StoreDescription desc = getRegistry().findDescription(context.getClass()); - if (desc != null && desc.isStoreSeparate() && desc.isExternalAllowed() && - context.getConfigFile() != null) { - // Capture the external context file in memory instead of - // writing it. The element is written inline (storeSeparate - // is temporarily off) so the separate-file branch of - // StandardContextSF.store is not re-entered. - StringWriter buffer = new StringWriter(); - PrintWriter w = new PrintWriter(buffer); - storeXMLHead(w); - boolean savedSeparate = desc.isStoreSeparate(); - desc.setStoreSeparate(false); - try { - super.store(w, -2, aContext); - } finally { - desc.setStoreSeparate(savedSeparate); + if (desc != null && desc.isStoreSeparate() && desc.isExternalAllowed()) { + URL configFile = context.getConfigFile(); + if (configFile == null && !desc.isExternalOnly() && !context.getDeployedFromServerXml()) { + // The store creates a new configuration file for a context that was not deployed from + // server.xml. Compute the path that file would get, without setting it on the context. + Host host = (Host) context.getParent(); + ContextName cn = new ContextName(context.getName(), false); + File config = new File(host.getConfigBaseFile(), cn.getBaseName() + ".xml"); + configFile = config.toURI().toURL(); + } + if (configFile != null) { + // Capture the (new) external context file in memory instead of writing it. The element is + // written inline (storeSeparate is temporarily off) so the separate-file branch of + // StandardContextSF.store is not re-entered. + StringWriter buffer = new StringWriter(); + PrintWriter w = new PrintWriter(buffer); + storeXMLHead(w); + boolean savedSeparate = desc.isStoreSeparate(); + desc.setStoreSeparate(false); + try { + super.store(w, -2, aContext); + } finally { + desc.setStoreSeparate(savedSeparate); + } + w.flush(); + captured.put(displayPath(configFile), buffer.toString()); + return; } - w.flush(); - captured.put(displayPath(context.getConfigFile()), buffer.toString()); - return; } } super.store(aWriter, indent, aContext); diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java index 9503f3fb0164..0f49fe4ab4a3 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -1300,12 +1300,18 @@ public void testRealm() throws Exception { Assert.assertEquals("MemoryRealm", realmDetail.get("name")); Assert.assertEquals(Boolean.FALSE, realmDetail.get("acceptsSubRealm")); - // The combined realm and its sub realms are part of the - // stored server.xml. + // The context was not deployed from server.xml and has no + // configuration file, so the store gives it a new context file + // instead of inlining it in server.xml. The combined realm and + // its sub realms are part of that file, not of the server.xml. request(client, "GET", MANAGER2 + "/api/config/store/preview", null, null, 200); - String xml = (String) parseObject(client.getResponseBody()).get("xml"); - Assert.assertTrue(xml.contains("org.apache.catalina.realm.LockOutRealm")); - Assert.assertTrue(xml.contains("org.apache.catalina.realm.MemoryRealm")); + Map preview = parseObject(client.getResponseBody()); + String xml = (String) preview.get("xml"); + Assert.assertFalse(xml.contains("org.apache.catalina.realm.LockOutRealm")); + @SuppressWarnings("unchecked") + List previewFiles = (List) preview.get("files"); + Assert.assertTrue("Expected a new context file for /realmapp: " + previewFiles, + previewFiles.stream().anyMatch(f -> String.valueOf(f).endsWith("realmapp.xml"))); // Remove --------------------------------------------------- From 960fee1d6f802769cddddaa4860f62b57c411b55 Mon Sep 17 00:00:00 2001 From: remm Date: Fri, 18 Sep 2026 14:57:53 +0200 Subject: [PATCH 15/17] Add missing localization --- .../apache/tomcat/manager2/ConfigApiServlet.java | 12 ++++++------ .../apache/tomcat/manager2/LocalStrings.properties | 8 ++++++++ .../java/org/apache/tomcat/manager2/LogParser.java | 13 ++++++++----- .../apache/tomcat/manager2/StatusApiServlet.java | 2 +- .../org/apache/tomcat/manager2/UsersApiServlet.java | 7 ++++--- .../apache/tomcat/manager2/TestManager2Webapp.java | 3 ++- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 661a17661825..285e6109f3c8 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -2979,7 +2979,7 @@ private void addChecked(String label, Object component, Runnable add, Runnable u if (component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { rollback(label, undo); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - Strings.sm().getString("manager2.configStartFailed", label, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", label, Strings.sm().getString("manager2.configNotStarted"))); } } @@ -3680,7 +3680,7 @@ private void addContainerRealm(HttpServletResponse response, Container container if (running && realm instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { container.setRealm(oldRealm); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - Strings.sm().getString("manager2.configStartFailed", className, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", className, Strings.sm().getString("manager2.configNotStarted"))); } log(Strings.sm().getString("manager2.configAuditAdd", "realm", className)); Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); @@ -3816,7 +3816,7 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { context.setManager(oldManager); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - Strings.sm().getString("manager2.configStartFailed", className, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", className, Strings.sm().getString("manager2.configNotStarted"))); } } case "loader" -> { @@ -3834,7 +3834,7 @@ private void addContextComponent(HttpServletResponse response, NodeRef parent, M if (running && component instanceof Lifecycle lifecycle && !lifecycle.getState().isAvailable()) { context.setLoader(oldLoader); throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - Strings.sm().getString("manager2.configStartFailed", className, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", className, Strings.sm().getString("manager2.configNotStarted"))); } } case "resources" -> { @@ -4995,7 +4995,7 @@ private void stopChecked(Lifecycle lifecycle, String label) throws ConfigExcepti } if (lifecycle.getState().isAvailable()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", - Strings.sm().getString("manager2.configStopFailed", label, "the component did not stop")); + Strings.sm().getString("manager2.configStopFailed", label, Strings.sm().getString("manager2.configNotStopped"))); } } @@ -5013,7 +5013,7 @@ private void startChecked(Lifecycle lifecycle, String label) throws ConfigExcept } if (!lifecycle.getState().isAvailable()) { throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", - Strings.sm().getString("manager2.configStartFailed", label, "the component did not start")); + Strings.sm().getString("manager2.configStartFailed", label, Strings.sm().getString("manager2.configNotStarted"))); } } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties index 1277af626ac0..c9174dea76cd 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -21,9 +21,14 @@ manager2.error.jmx=Error querying the MBean server manager2.error.status=Error collecting runtime status manager2.error.upload=Error during WAR upload manager2.history.invalidParameter=The init parameter [{0}] of the status API servlet has an invalid value [{1}]. The default value will be used. +manager2.historyNotAvailable=The status history is not available. manager2.error.logs=Error reading a log file manager2.logsDirMissing=The logs directory of this Tomcat instance could not be found. manager2.invalidLogName=The log file name is missing or invalid. +manager2.accessPatternDangling=The access log pattern [{0}] ends with a trailing percent sign. +manager2.accessPatternMalformed=The access log pattern [{0}] is malformed. +manager2.accessPatternUnsupported=The access log pattern contains an unsupported directive [{0}]. +manager2.accessPatternInvalid=The access log pattern [{0}] is invalid. manager2.error.users=Error managing the user database manager2.userDatabaseMissing=No user database is configured for this server. Add a UserDatabase JNDI resource (for example the file based one from the default server.xml) to manage users, groups and roles. @@ -48,6 +53,7 @@ manager2.rolenameMissing=The role name [{0}] is missing or invalid. manager2.passwordMissing=The password is missing. manager2.invalidJson=Invalid JSON in request body: {0} +manager2.expectedStringArray=expected an array of strings manager2.missingPath=The context path was not specified or is invalid. manager2.sessionNotFound=Session [{0}] was not found. manager2.attributeRemoved=Session attribute [{0}] removed. @@ -112,6 +118,8 @@ manager2.configStarted=The component [{0}] has been started. manager2.configRestarted=The component [{0}] has been restarted. manager2.configAlreadyRunning=The component [{0}] is already running. manager2.configAlreadyStopped=The component [{0}] is already stopped. +manager2.configNotStarted=the component did not start +manager2.configNotStopped=the component did not stop manager2.configAuditAttribute=Config: set attribute [{0}] of [{1}] to [{2}] manager2.configAuditAdd=Config: added {0} [{1}] manager2.configAuditRemove=Config: removed {0} [{1}] diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java index 10e1fb09a5c4..40f324a9bf53 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java @@ -240,7 +240,8 @@ public static AccessParser compile(String pattern) { literal.setLength(0); } if (i + 1 >= pattern.length()) { - throw new IllegalArgumentException("Dangling '%' in access log pattern: " + pattern); + throw new IllegalArgumentException( + Strings.sm().getString("manager2.accessPatternDangling", pattern)); } char next = pattern.charAt(++i); if (next == '%') { @@ -250,14 +251,15 @@ public static AccessParser compile(String pattern) { if (next == '{') { int close = pattern.indexOf('}', i + 1); if (close < 0 || close + 1 >= pattern.length()) { - throw new IllegalArgumentException("Malformed access log pattern: " + pattern); + throw new IllegalArgumentException( + Strings.sm().getString("manager2.accessPatternMalformed", pattern)); } String key = pattern.substring(i + 1, close); char directive = pattern.charAt(close + 1); String field = AccessLogSupport.keyedField(directive, key); if (field == null) { throw new IllegalArgumentException( - "Unsupported directive %{%s}%c in access log pattern".formatted(key, directive)); + Strings.sm().getString("manager2.accessPatternUnsupported", "%{" + key + "}%" + directive)); } regex.append("([^\\\"]*)"); groupFields.add(field); @@ -266,7 +268,8 @@ public static AccessParser compile(String pattern) { } String[] directive = AccessLogSupport.directives().get(next); if (directive == null) { - throw new IllegalArgumentException("Unsupported directive %c in access log pattern".formatted(next)); + throw new IllegalArgumentException( + Strings.sm().getString("manager2.accessPatternUnsupported", "%" + next)); } regex.append(directive[0]); groupFields.add(directive[1]); @@ -279,7 +282,7 @@ public static AccessParser compile(String pattern) { try { regexPattern = Pattern.compile(regex.toString()); } catch (RuntimeException e) { - throw new IllegalArgumentException("Invalid access log pattern: " + pattern, e); + throw new IllegalArgumentException(Strings.sm().getString("manager2.accessPatternInvalid", pattern), e); } return new AccessParser(regexPattern, groupFields, AccessLogSupport.displayFields(groupFields)); diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java index 42771a9b30d2..08de95c85794 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java @@ -220,7 +220,7 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro } else if (path.startsWith("/api/status/history")) { if (history == null) { Api.error(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "NOT_AVAILABLE", - "The status history is not available."); + Strings.sm().getString("manager2.historyNotAvailable")); } else { Api.json(response, history.payload()); } diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java index d597d1d33417..727e98864322 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java @@ -890,13 +890,14 @@ private static List asStringList(Object value) { return new ArrayList<>(); } if (!(value instanceof List list)) { - throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", "expected an array of strings")); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", + Strings.sm().getString("manager2.expectedStringArray"))); } List result = new ArrayList<>(); for (Object item : list) { if (!(item instanceof String s) || !validName(s)) { - throw new IllegalArgumentException( - Strings.sm().getString("manager2.invalidJson", "expected an array of strings")); + throw new IllegalArgumentException(Strings.sm().getString("manager2.invalidJson", + Strings.sm().getString("manager2.expectedStringArray"))); } result.add(s); } diff --git a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java index 6512b47ed17a..69f2fa02d17d 100644 --- a/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -1389,7 +1389,8 @@ private void createTestWebapp(File dir) throws IOException { private File createTestWar() throws IOException { File warFile = new File(TEMP_DIR, "manager2-test.war"); deleteRecursive(warFile); - try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(warFile))) { + try (FileOutputStream fos = new FileOutputStream(warFile); + JarOutputStream jos = new JarOutputStream(fos)) { jos.putNextEntry(new JarEntry("index.html")); jos.write("manager2 war test".getBytes(StandardCharsets.UTF_8)); jos.closeEntry(); From 6843c98f46c0cf2d22ca3718eb0e3241f187f0c9 Mon Sep 17 00:00:00 2001 From: remm Date: Fri, 18 Sep 2026 16:11:59 +0200 Subject: [PATCH 16/17] Optimize instanceof --- .../tomcat/manager2/ConfigApiServlet.java | 87 +------------------ 1 file changed, 4 insertions(+), 83 deletions(-) diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java index 285e6109f3c8..02628579216d 100644 --- a/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java @@ -671,9 +671,7 @@ private List> childrenOfExecutors(StandardService service, S entry.put("type", "executor"); entry.put("className", executor.getClass().getName()); entry.put("name", executor.getName()); - if (executor instanceof Lifecycle lifecycle) { - entry.put("state", lifecycle.getState().toString()); - } + entry.put("state", executor.getState().toString()); entry.put("children", new ArrayList>()); result.add(entry); } @@ -2018,6 +2016,7 @@ private NodeRef resolve(String id) throws ConfigException { Object current = server; Object parent = null; + String type = "server"; for (int i = 1; i < segments.length; i += 2) { if (i + 1 >= segments.length) { @@ -2501,88 +2500,10 @@ private NodeRef resolve(String id) throws ConfigException { } default -> throw notFound(); } - } - String type; - if (current instanceof Server) { - type = "server"; - } else if (current instanceof Service) { - type = "service"; - } else if (current instanceof Engine) { - type = "engine"; - } else if (current instanceof Host) { - type = "host"; - } else if (current instanceof Context) { - type = "context"; - } else if (current instanceof Wrapper) { - type = "wrapper"; - } else if (current instanceof Connector) { - type = "connector"; - } else if (current instanceof Executor) { - type = "executor"; - } else if (current instanceof Realm) { - type = "realm"; - } else if (current instanceof ClusterManager) { - type = "clusterManager"; - } else if (current instanceof Manager) { - type = "manager"; - } else if (current instanceof CatalinaCluster) { - type = "cluster"; - } else if (current instanceof Channel) { - type = "channel"; - } else if (current instanceof MembershipService) { - type = "membership"; - } else if (current instanceof ChannelSender) { - type = "sender"; - } else if (current instanceof MultiPointSender) { - type = "transport"; - } else if (current instanceof ChannelReceiver) { - type = "receiver"; - } else if (current instanceof ClusterDeployer) { - type = "deployer"; - } else if (current instanceof ClusterValve) { - type = "clusterValve"; - } else if (current instanceof ClusterListener) { - type = "clusterListener"; - } else if (current instanceof Member) { - type = "member"; - } else if (current instanceof ChannelInterceptor) { - type = "interceptor"; - } else if (current instanceof SessionIdGenerator) { - type = "sessionIdGenerator"; - } else if (current instanceof WebResourceRoot) { - type = "resources"; - } else if (current instanceof Loader) { - type = "loader"; - } else if (current instanceof CookieProcessor) { - type = "cookieProcessor"; - } else if (current instanceof NamingResourcesImpl) { - type = "namingResources"; - } else if (current instanceof ContextResource) { - type = "resource"; - } else if (current instanceof ContextResourceLink) { - type = "resourceLink"; - } else if (current instanceof ContextResourceEnvRef) { - type = "resourceEnvRef"; - } else if (current instanceof ContextEnvironment) { - type = "environment"; - } else if (current instanceof ContextEjb) { - type = "ejb"; - } else if (current instanceof ContextLocalEjb) { - type = "localEjb"; - } else if (current instanceof ContextService) { - type = "serviceRef"; - } else if (current instanceof SSLHostConfig) { - type = "sslHostConfig"; - } else if (current instanceof SSLHostConfigCertificate) { - type = "certificate"; - } else if (current instanceof UpgradeProtocol) { - type = "upgradeProtocol"; - } else if (current instanceof LifecycleListener) { - type = "listener"; - } else { - type = "valve"; + type = "localMember".equals(kind) ? "member" : kind; } + return new NodeRef(current, parent, type, null, id); } From 553d6d020c5ab96352808b6f1a5a7eeca76bee64 Mon Sep 17 00:00:00 2001 From: remm Date: Sun, 20 Sep 2026 22:28:29 +0200 Subject: [PATCH 17/17] Fix alignment when sidebar pops out --- modules/manager2/webapp/css/manager2.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/modules/manager2/webapp/css/manager2.css b/modules/manager2/webapp/css/manager2.css index a23aad340354..1a39d6c15d3e 100644 --- a/modules/manager2/webapp/css/manager2.css +++ b/modules/manager2/webapp/css/manager2.css @@ -1149,7 +1149,10 @@ pre.block { left: 0; width: var(--sidenav-w); z-index: 30; - padding: var(--space-4) var(--space-3); + /* Padding matches the collapsed rail (top var(--space-3), left + var(--space-2)) so the icon rows and the item pills do not shift + when the rail pops out; only the right padding grows. */ + padding: var(--space-3) var(--space-3) var(--space-3) var(--space-2); border-radius: 0 var(--radius) var(--radius) 0; box-shadow: var(--shadow-2); scrollbar-width: auto; @@ -1160,11 +1163,17 @@ pre.block { .sidenav:focus-within .nav-item { justify-content: flex-start; gap: 10px; - padding: 9px 12px; + /* With the container's 8 px left padding this lands the icon at + 20.5 px, where the collapsed rail centres it in the 60 px column + ((60 - 8 - 8 - 1 - 18) / 2 + 8 = 20.5); hence the half pixel. */ + padding: 10px 12px 10px 12.5px; } .sidenav:hover .nav-label, .sidenav:focus-within .nav-label { display: block; + /* Keep the label's line box under the 18 px icon so the item height + (and therefore the icon rows) match the collapsed rail exactly. */ + line-height: 1; animation: sidenav-label-in 120ms ease; } }