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/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]); 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..bef3fab2b04f --- /dev/null +++ b/modules/manager2/manager2-design.md @@ -0,0 +1,1231 @@ +# 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, 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 + +``` + +----------------------------------------------------------+ + | 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** โ€” 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 + +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/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 | +| 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/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) | + | 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`, `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`) | + | 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`. +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 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 + 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 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. + +### 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), 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 `` + 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. +- *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; + host โ†’ context, alias, realm, valve, cluster; context โ†’ wrapper, + realm, manager, resources, loader, cookieProcessor, valve, cluster; + 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), + 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. + - *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 + (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** +- 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, + 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. + +**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 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 + 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 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 + 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, 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 + +- 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). 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. 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; 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 + (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 + 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 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/*`, + `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, 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
+                               (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; 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/
+     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), 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
+    *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.
+
+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 (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
+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/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..ff963ab3ce59
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java
@@ -0,0 +1,114 @@
+/*
+ * 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",
+                Strings.sm().getString("manager2.notFound"));
+    }
+
+
+    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..f41cd6f2917a
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java
@@ -0,0 +1,758 @@
+/*
+ * 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;
+
+
+    @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",
+                        Strings.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",
+                        Strings.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",
+                        Strings.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",
+                        Strings.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);
+
+        try {
+            Part warPart = request.getPart("war");
+            if (warPart == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "UPLOAD_NO_FILE",
+                        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",
+                        Strings.sm().getString("manager2.uploadNotWar", 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",
+                        Strings.sm().getString("manager2.alreadyDeployed", 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",
+                        Strings.sm().getString("manager2.uploadWarExists", 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",
+                                    Strings.sm().getString("manager2.uploadDeleteFailed", deployedWar));
+                            return;
+                        }
+                        if (!target.renameTo(deployedWar)) {
+                            Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "RENAME_FAILED",
+                                    Strings.sm().getString("manager2.uploadRenameFailed", target, deployedWar));
+                            return;
+                        }
+                    }
+                } finally {
+                    removeServiced(name);
+                }
+                check(name);
+            } else {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "IN_SERVICE",
+                        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 = Strings.sm().getString("manager2.deployed", cn.getDisplayName());
+            } else if (deployed != null && !deployed.getState().isAvailable()) {
+                message = Strings.sm().getString("manager2.deployedNotStarted", cn.getDisplayName());
+            } else {
+                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(Strings.sm().getString("manager2.error.upload"), e);
+            Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "UPLOAD_FAILED",
+                    Strings.sm().getString("manager2.uploadFailed", 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",
+                        Strings.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",
+                        Strings.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",
+                        Strings.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",
+                        Strings.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", 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());
+        }
+    }
+
+
+    private void handleRemoveAttribute(HttpServletRequest request, HttpServletResponse response, ContextName cn)
+            throws IOException {
+        try {
+            if (cn == null) {
+                Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH",
+                        Strings.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, Strings.sm().getString("manager2.attributeRemoved", attributeName));
+            } else {
+                Api.error(response, HttpServletResponse.SC_NOT_FOUND, "ATTRIBUTE_NOT_FOUND",
+                        Strings.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(Strings.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();
+    }
+
+
+    /**
+     * 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",
+                        Strings.sm().getString("manager2.pathCheckFail", input, expected));
+                return false;
+            }
+        } catch (IOException ioe) {
+            Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "PATH_CHECK_ERROR",
+                    Strings.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..02628579216d
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/ConfigApiServlet.java
@@ -0,0 +1,5353 @@
+/*
+ * 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.storeconfig.XMLFormatPreserver;
+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.ContextName;
+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;
+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;
+
+
+/**
+ * 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 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. + *

+ * 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 + * {@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}/connector/{i}/upgradeProtocol/{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), 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 + * 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. + *

+ * 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 + * 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; + + + /** + * 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(Strings.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 if ("/api/config/lifecycle".equals(path)) { + lifecycle(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(Strings.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(Strings.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)); + children.addAll(upgradeProtocolChildren(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(); + } + + + /** + * 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(); + 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()); + List> connectorChildren = sslHostConfigChildren(connector, id); + connectorChildren.addAll(upgradeProtocolChildren(connector, id)); + entry.put("children", connectorChildren); + 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()); + entry.put("state", executor.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 UpgradeProtocol upgradeProtocol) { + return upgradeProtocolLabel(upgradeProtocol); + } + 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); + // 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)); + } + 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. + 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()); + String description = attributeDescription(explicit.scope(), paramScope, attribute); + if (description != null) { + entry.put("description", description); + } + 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()); + // 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()))); + 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. + * 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 { + + private final String name; + + private final String type; + + private final boolean writable; + + private final boolean param; + + + ExplicitAttribute(String name, String type, boolean writable) { + this(name, type, writable, false); + } + + + ExplicitAttribute(String name, String type, boolean writable, boolean param) { + this.name = name; + this.type = type; + this.writable = writable; + this.param = param; + } + + + String getName() { + return name; + } + + + String getType() { + return type; + } + + + boolean isWritable() { + return writable; + } + + + /** + * {@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), + 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), + 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), + 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), + 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), + new ExplicitAttribute("jvmRoute", "java.lang.String", true), + new ExplicitAttribute("sessionIdLength", "int", true)); + + + // 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), + 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 + + // 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), + 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), + 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), + 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), + 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), + 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), + 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)); + + + // ------------------------------------------------- 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), + 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), + 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), + 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), + 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), + 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), + 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), + 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. + + 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"), + 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"), + option("readonly", "boolean"), + option("watchSource", "boolean")); + + private static final List DATA_SOURCE_USER_DATABASE_FACTORY_OPTIONS = List.of( + 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"), + 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"), + option("defaultMaxIdle", "int"), + option("defaultMaxWaitMillis", "long")); + + private static final List SHARED_POOL_DATA_SOURCE_FACTORY_OPTIONS = poolOptions( + option("maxTotal", "int")); + + 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) { + return new ExplicitAttribute(name, type, true, 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, 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 ExplicitSpec explicitAttributes(Object component, String type) { + if ("sslHostConfig".equals(type)) { + return new ExplicitSpec("sslHostConfig", SSL_HOST_CONFIG_ATTRIBUTES); + } + if ("certificate".equals(type)) { + return new ExplicitSpec("certificate", CERTIFICATE_ATTRIBUTES); + } + if (component instanceof WebappLoader) { + return new ExplicitSpec("loader", WEBAPP_LOADER_ATTRIBUTES); + } + if (component instanceof CookieProcessorBase) { + return new ExplicitSpec("cookieProcessor", COOKIE_PROCESSOR_ATTRIBUTES); + } + if (component instanceof SessionIdGeneratorBase) { + return new ExplicitSpec("sessionIdGenerator", SESSION_ID_GENERATOR_ATTRIBUTES); + } + if (component instanceof Http2Protocol) { + return new ExplicitSpec("http2Protocol", HTTP2_PROTOCOL_ATTRIBUTES); + } + if (component instanceof GroupChannel) { + return new ExplicitSpec("channel", CHANNEL_ATTRIBUTES); + } + if (component instanceof McastService) { + return new ExplicitSpec("membership", MCAST_ATTRIBUTES); + } + if (component instanceof ReceiverBase) { + return new ExplicitSpec("receiver", RECEIVER_ATTRIBUTES); + } + if (component instanceof AbstractSender) { + return new ExplicitSpec("transport", TRANSPORT_ATTRIBUTES); + } + if (component instanceof MessageDispatchInterceptor) { + return new ExplicitSpec("messageDispatchInterceptor", MESSAGE_DISPATCH_INTERCEPTOR_ATTRIBUTES); + } + if (component instanceof TcpFailureDetector) { + return new ExplicitSpec("tcpFailureDetector", TCP_FAILURE_DETECTOR_ATTRIBUTES); + } + if (component instanceof ChannelInterceptor) { + return new ExplicitSpec("channelInterceptor", INTERCEPTOR_ATTRIBUTES); + } + if (isNamingEntry(type) && component instanceof ResourceBase) { + return new ExplicitSpec(type, namingEntryAttributes(component, type)); + } + 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).attributes()) { + 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", + Strings.sm().getString("manager2.configNoSetter", method)); + } catch (InvocationTargetException e) { + Throwable t = e.getTargetException(); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", + 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", + Strings.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", + Strings.sm().getString("manager2.configInvalidId")); + } + + String[] segments = id.split("/", -1); + if (!"server".equals(segments[0])) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + Strings.sm().getString("manager2.configNotFound")); + } + + Object current = server; + Object parent = null; + String type = "server"; + + for (int i = 1; i < segments.length; i += 2) { + if (i + 1 >= segments.length) { + throw new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + Strings.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 "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(); + } + 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(); + } + + type = "localMember".equals(kind) ? "member" : kind; + } + + return new NodeRef(current, parent, type, null, id); + } + + + private static ConfigException notFound() { + return new ConfigException(HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND", + Strings.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", + 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", + Strings.sm().getString("manager2.configSelfComponent")); + } + + // Components without a modeler descriptor take their attributes + // from the explicit list. + if (!explicitAttributes(ref.component, ref.type).attributes().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", + Strings.sm().getString("manager2.configAttributeNotFound", name)); + } + if (!attribute.isWriteable() || !EDITABLE_TYPES.contains(attribute.getType())) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", + Strings.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", + Strings.sm().getString("manager2.configConfirmRequired", expected)); + } + } + + Object value = convert(attribute.getType(), body.get("value")); + setValue(ref.component, attribute, value); + + log(Strings.sm().getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + String.valueOf(value))); + Api.ok(response, Strings.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", + Strings.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", + 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, true); + } else if (!attribute.isWritable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "READ_ONLY", + Strings.sm().getString("manager2.configReadOnly", name)); + } + + 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); + + // 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(Strings.sm().getString("manager2.error.config"), rollbackError); + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "UPDATE_FAILED", + Strings.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", Strings.sm().getString( + "manager2.configSslReloadFailed", name, displayName(ref.component, ref.type), rootMessage(e))); + } + } + + log(Strings.sm().getString("manager2.configAuditAttribute", name, displayName(ref.component, ref.type), + String.valueOf(value))); + Api.ok(response, Strings.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(Strings.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 = Strings.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", + Strings.sm().getString("manager2.configNoSetter", method)); + } catch (InvocationTargetException e) { + Throwable t = e.getTargetException(); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "SET_FAILED", + 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", + Strings.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(Strings.sm().getString("manager2.error.config"), e); + rollback(label, undo); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + 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", + Strings.sm().getString("manager2.configStartFailed", label, Strings.sm().getString("manager2.configNotStarted"))); + } + } + + + private void rollback(String label, Runnable undo) { + try { + undo.run(); + } catch (Exception e) { + log(Strings.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", + Strings.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 "upgradeProtocol" -> addUpgradeProtocol(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", + Strings.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", + Strings.sm().getString("manager2.configInvalidName", alias)); + } + if (Arrays.asList(host.findAliases()).contains(alias)) { + throw duplicate(alias); + } + host.addAlias(alias); + log(Strings.sm().getString("manager2.configAuditAdd", "alias", alias)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "service", name)); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configInvalidPath", appBase)); + } + if (!appBaseFile.mkdirs() && !appBaseFile.isDirectory()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", + Strings.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(Strings.sm().getString("manager2.configAuditAdd", "host", name)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.configAuditAdd", "context", path)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.configAuditAdd", "wrapper", name)); + Api.ok(response, Strings.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", + Strings.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", + Strings.sm().getString("manager2.configInvalidClass", className)); + } + Pipeline pipeline = ((Container) parent.component).getPipeline(); + addChecked(className, valve, () -> pipeline.addValve(valve), () -> pipeline.removeValve(valve)); + log(Strings.sm().getString("manager2.configAuditAdd", "valve", className)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(Strings.sm().getString("manager2.configAuditAdd", "listener", className)); + Api.ok(response, Strings.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", + Strings.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(Strings.sm().getString("manager2.configAuditAdd", "cluster", label)); + Api.ok(response, Strings.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", + Strings.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", + Strings.sm().getString("manager2.configInvalidClass", className)); + } + if (!(valve instanceof ClusterValve)) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "INVALID_CLASS", + Strings.sm().getString("manager2.configInvalidClass", className)); + } + try { + cluster.addValve(valve); + } catch (Exception e) { + log(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(Strings.sm().getString("manager2.configAuditAdd", "clusterValve", className)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "channel", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "membership", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "sender", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "receiver", label)); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configInvalidName", "className")); + } + ChannelInterceptor interceptor = newClusterComponent(className, null, ChannelInterceptor.class); + try { + managed.addInterceptor(interceptor); + } catch (Exception e) { + log(Strings.sm().getString("manager2.error.config"), e); + throw addFailed(className, e); + } + log(Strings.sm().getString("manager2.configAuditAdd", "interceptor", className)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "deployer", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "clusterManager", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.configAuditAdd", "transport", label)); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configInvalidName", "className")); + } + ClusterListener listener = newClusterComponent(className, null, ClusterListener.class); + try { + cluster.addClusterListener(listener); + } catch (Exception e) { + log(Strings.sm().getString("manager2.error.config"), e); + throw addFailed(className, e); + } + log(Strings.sm().getString("manager2.configAuditAdd", "clusterListener", className)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", className)); + } + + + private static ConfigException addFailed(String label, Exception e) { + return new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.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", + 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)); + } + + + 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", + Strings.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(Strings.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", + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(Strings.sm().getString("manager2.configAuditAdd", "realm", className)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + 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 + // 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", + Strings.sm().getString("manager2.configStartFailed", className, Strings.sm().getString("manager2.configNotStarted"))); + } + } + 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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + 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", + Strings.sm().getString("manager2.configStartFailed", className, Strings.sm().getString("manager2.configNotStarted"))); + } + } + 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", + 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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); + } + } + } + log(Strings.sm().getString("manager2.configAuditAdd", type, className)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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(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). + 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", + Strings.sm().getString("manager2.configAddFailed", className, rootMessage(e))); + } + log(Strings.sm().getString("manager2.configAuditAdd", "sessionIdGenerator", className)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.sm().getString("manager2.configAddFailed", name, rootMessage(e))); + } + log(Strings.sm().getString("manager2.configAuditAdd", type, name)); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configSetFailed", name, Strings.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", + Strings.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", + 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(Strings.sm().getString("manager2.configAuditAdd", "connector", protocol + " (port " + port + ")")); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configExecutorInvalid")); + } + addChecked(name, executor, () -> service.addExecutor(executor), () -> service.removeExecutor(executor)); + log(Strings.sm().getString("manager2.configAuditAdd", "executor", name)); + Api.ok(response, Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.sm().getString("manager2.configInvalidValue", "certificate")); + } + } + + try { + connector.addSslHostConfig(sslHostConfig); + } catch (IllegalArgumentException e) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.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(Strings.sm().getString("manager2.configRollbackFailed", hostNameLower, rootMessage(e2)), e2); + } + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "ADD_FAILED", + Strings.sm().getString("manager2.configAddFailed", hostNameLower, rootMessage(e))); + } + } + + log(Strings.sm().getString("manager2.configAuditAdd", "sslHostConfig", hostNameLower)); + Api.ok(response, Strings.sm().getString("manager2.configAdded", hostNameLower)); + } + + + /** + * 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", + Strings.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", + 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 + // 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(Strings.sm().getString("manager2.configAuditAdd", "upgradeProtocol", label)); + Api.ok(response, Strings.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 + * 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", + Strings.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", + Strings.sm().getString("manager2.configAddFailed", certificateLabel(certificate), rootMessage(e))); + } + } + + log(Strings.sm().getString("manager2.configAuditAdd", "certificate", certificateLabel(certificate))); + Api.ok(response, Strings.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", + Strings.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", + Strings.sm().getString("manager2.configInvalidValue", name)); + } + } + + + /** + * 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 http11ProtocolHandler(Connector connector) { + if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol http11) { + return http11; + } + return null; + } + + + /** + * 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. + */ + @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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SERVER_UNAVAILABLE", + Strings.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(); + } + + + @SuppressWarnings("rawtypes") + 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", + Strings.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", + Strings.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", + 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", + 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", + Strings.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", + 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", + 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", + Strings.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", + "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", + Strings.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 "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); + 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", + Strings.sm().getString("manager2.configRemoveFailed", + e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + } + + log(Strings.sm().getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, Strings.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", + Strings.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", + 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(Strings.sm().getString("manager2.configAuditRemove", ref.type, label)); + Api.ok(response, Strings.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", + Strings.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", + Strings.sm().getString("manager2.configRemoveFailed", rootMessage(e))); + } + } + + String label = displayName(certificate, "certificate"); + log(Strings.sm().getString("manager2.configAuditRemove", "certificate", label)); + Api.ok(response, Strings.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", + Strings.sm().getString("manager2.configSelfComponent")); + } + + + private static ConfigException duplicate(String name) { + return new ConfigException(HttpServletResponse.SC_CONFLICT, "DUPLICATE", + Strings.sm().getString("manager2.configDuplicate", name)); + } + + + private static ConfigException badParent(String type) { + return new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "BAD_PARENT", + Strings.sm().getString("manager2.configParentInvalid", 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", + 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", + 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", + Strings.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, Strings.sm().getString("manager2.configAlreadyStopped", label)); + return; + } + stopChecked(lifecycle, label); + log(Strings.sm().getString("manager2.configAuditLifecycle", "stopped", label)); + Api.ok(response, Strings.sm().getString("manager2.configStopped", label)); + return; + } + + if (restart && lifecycle.getState().isAvailable()) { + stopChecked(lifecycle, label); + } + if (start && lifecycle.getState().isAvailable()) { + Api.ok(response, Strings.sm().getString("manager2.configAlreadyRunning", label)); + return; + } + startChecked(lifecycle, label); + log(Strings.sm().getString("manager2.configAuditLifecycle", restart ? "restarted" : "started", label)); + Api.ok(response, Strings.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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", + Strings.sm().getString("manager2.configStopFailed", label, rootMessage(e))); + } + if (lifecycle.getState().isAvailable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "STOP_FAILED", + Strings.sm().getString("manager2.configStopFailed", label, Strings.sm().getString("manager2.configNotStopped"))); + } + } + + + /** + * 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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + Strings.sm().getString("manager2.configStartFailed", label, rootMessage(e))); + } + if (!lifecycle.getState().isAvailable()) { + throw new ConfigException(HttpServletResponse.SC_BAD_REQUEST, "START_FAILED", + Strings.sm().getString("manager2.configStartFailed", label, Strings.sm().getString("manager2.configNotStarted"))); + } + } + + + /** + * 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 + + + /** + * 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(Strings.sm().getString("manager2.error.config"), e); + throw new ConfigException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "STORE_FAILED", + Strings.sm().getString("manager2.configStoreFailed")); + } + mover.move(); + + Set after = serverXmlBackups(confDir); + after.removeAll(before); + String backup = after.isEmpty() ? null : after.iterator().next(); + + log(Strings.sm().getString("manager2.configAuditStore", backup)); + Map payload = new LinkedHashMap<>(); + payload.put("ok", Boolean.TRUE); + 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); + } + + + /** + * 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 { + StoreFileMover mover = + new StoreFileMover(Bootstrap.getCatalinaBase(), storeConfig.getServerFilename(), storeConfig.getRegistry().getEncoding()); + + 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); + } + 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); + 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()) { + 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; + } + } + } + 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", + Strings.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(Strings.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", + Strings.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..f91772abb453 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/CsrfFilter.java @@ -0,0 +1,144 @@ +/* + * 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; + + +/** + * 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 { + + + /** + */ + 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, Strings.sm().getString("csrfFilter.invalid")); + Api.error(resp, HttpServletResponse.SC_FORBIDDEN, "CSRF", Strings.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..1ac6fad5ea88 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HomeServlet.java @@ -0,0 +1,85 @@ +/* + * 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 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 + * 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) { + String template = Html.readTemplate(getServletContext(), "/index.html"); + if (template == null) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + Strings.manager(request).getString("manager2.shellMissing")); + return; + } + Html.render(request, response, template); + } 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..61b810567678 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/HostsApiServlet.java @@ -0,0 +1,248 @@ +/* + * 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; + + + @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, legacySm(request)))); + } else if (path.matches("/api/hosts/[^/]+/start")) { + 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), legacySm(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, legacySm(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, legacySm(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(Strings.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 legacySm(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..b3ac04a5d6f2 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Html.java @@ -0,0 +1,163 @@ +/* + * 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 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 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 { + + + /** + * 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 = ""; + + /** + * 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 + } + + + /** + * 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: 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 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 { + 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/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..c9174dea76cd --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LocalStrings.properties @@ -0,0 +1,1056 @@ +# ----------------------------------------------------------------------------- +# 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.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. +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.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. +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, 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. +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.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. +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.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}] +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/LogParser.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java new file mode 100644 index 000000000000..40f324a9bf53 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogParser.java @@ -0,0 +1,295 @@ +/* + * 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( + Strings.sm().getString("manager2.accessPatternDangling", 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( + 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( + Strings.sm().getString("manager2.accessPatternUnsupported", "%{" + key + "}%" + directive)); + } + regex.append("([^\\\"]*)"); + groupFields.add(field); + i = close + 1; + continue; + } + String[] directive = AccessLogSupport.directives().get(next); + if (directive == null) { + throw new IllegalArgumentException( + Strings.sm().getString("manager2.accessPatternUnsupported", "%" + 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(Strings.sm().getString("manager2.accessPatternInvalid", 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..06682d91a704 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LoginServlet.java @@ -0,0 +1,248 @@ +/* + * 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, + Strings.manager(request).getString("manager2.loginTemplateMissing")); + 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..d17a6e2113aa --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/LogsApiServlet.java @@ -0,0 +1,797 @@ +/* + * 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.nio.file.Files; +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; + + +/** + * 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 + * 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 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/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); + } + } catch (Exception e) { + log(Strings.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", + Strings.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 file = resolveRequestFile(response, request); + if (file == null) { + 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); + } + + + /** + * 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 + + + 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; + } + + + /** + * 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", + 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", + Strings.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. + */ + 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..08de95c85794 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusApiServlet.java @@ -0,0 +1,368 @@ +/* + * 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; + + +/** + * 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 { + + + @Serial + private static final long serialVersionUID = 1L; + + + 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(Strings.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(Strings.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", + Strings.sm().getString("manager2.historyNotAvailable")); + } 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 { + Api.notFound(response); + } + } catch (Exception e) { + log(Strings.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(Strings.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(Strings.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..29f4c0bcf9b8 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/StatusSnapshot.java @@ -0,0 +1,518 @@ +/* + * 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.lang.reflect.Method; +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 { + + + /* + * 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}. + * + * @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 instant CPU and memory snapshot served by {@code GET /api/status/system}. Unlike the compact live + * snapshot this is not collected into the history; it is computed on demand, so the values reflect the moment of + * the request. + * + * @return the snapshot + */ + public static Map 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}}. + * + * @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())); + + 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); + } + 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); + } + return poolList; + } + + + 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 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()); + } + 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..48dc37f95807 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Strings.java @@ -0,0 +1,229 @@ +/* + * 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.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; + + +/** + * 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. + *

+ * 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. + * + * @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 creation.get(); + } + thread.setContextClassLoader(own); + try { + 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 new file mode 100644 index 000000000000..727e98864322 --- /dev/null +++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/UsersApiServlet.java @@ -0,0 +1,912 @@ +/* + * 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; + + +/** + * 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 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(Strings.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(Strings.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(Strings.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", + Strings.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", + 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", + Strings.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", + Strings.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", + 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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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", + Strings.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(Strings.sm().getString("manager2.userDatabaseLookup", name), e); + } + } + } catch (NamingException e) { + log(Strings.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", + Strings.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", + Strings.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", + Strings.sm().getString("manager2.userDatabaseReadonly", db.getId())); + return false; + } + if (!writable(db)) { + Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "USER_DATABASE_NOT_WRITABLE", + Strings.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(Strings.sm().getString("manager2.error.users"), e); + Api.error(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "USER_DATABASE_SAVE_FAILED", + Strings.sm().getString("manager2.userDatabaseSaveFailed", String.valueOf(e.getMessage()))); + return; + } + Api.ok(response, Strings.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(Strings.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(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", + Strings.sm().getString("manager2.expectedStringArray"))); + } + 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..0f49fe4ab4a3 --- /dev/null +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Config.java @@ -0,0 +1,2548 @@ +/* + * 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, lifecycle operations (start / stop / restart), 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 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)); + 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")); + 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); + 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 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(); + + 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 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(" 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 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); + 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 --------------------------------------------------- + + // 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")); + + // 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); + 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 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); + } + + + 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/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 new file mode 100644 index 000000000000..69f2fa02d17d --- /dev/null +++ b/modules/manager2/src/test/java/org/apache/tomcat/manager2/TestManager2Webapp.java @@ -0,0 +1,1460 @@ +/* + * 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); + + // 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(); + } + + + @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")); + + // 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(); + } + + + @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 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); + + 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); + + // 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(); + } + + + @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")); + + // 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(); + } + + + @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")); + + // 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(); + } + + + @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); + 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(); + } + + + @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 (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(); + 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..13c0e5c3c048 --- /dev/null +++ b/modules/manager2/webapp/WEB-INF/web.xml @@ -0,0 +1,417 @@ + + + + + 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 + + + + + I18n + org.apache.tomcat.manager2.I18nServlet + + + + + + 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/logs/download + + + LogsApi + /api/access-log + + + LogsApi + /api/access-log/file + + + + LogsApi + /api/access-log/download + + + 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 + + + I18n + /i18n + + + + + + + Locale + org.apache.tomcat.manager2.LocaleFilter + + + Locale + /* + + + + + 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..1a39d6c15d3e --- /dev/null +++ b/modules/manager2/webapp/css/manager2.css @@ -0,0 +1,1199 @@ +/* + 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; + --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); + --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; + + /* 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"] { + --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; + + /* 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); +} + +/* ============================ 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); } +/* 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; + 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; } + +/* The sidenav is an icon rail at all widths where it is shown (the bottom + 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 โ€” + never resizes the view. */ +.shell-main { + display: grid; + grid-template-columns: var(--sidenav-icon-w) 1fr; + min-height: 0; +} + +.shell { grid-template-rows: var(--topbar-h) 1fr; } + +.sidenav { + display: flex; flex-direction: column; gap: 2px; + 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; justify-content: center; + gap: 0; + padding: 10px 0; + 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-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 + --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 { + grid-column: 2; + 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; } +.page-head h1 { overflow-wrap: anywhere; } +.head-spacer { flex: 1; } + +/* ============================ 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; } +.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); } + +.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-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; } +.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; } + +/* 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); } + +.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; } +.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 ===================================== */ + +.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); + 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; } + +/* ============================ 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 { + 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: 900px) { + .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; + 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; 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; } + .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; } +} + +/* ============================ Responsive: phone ======================== */ + +@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%; } + .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-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; } + + /* 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: 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 { display: none; } + + .icon-btn { min-width: 44px; min-height: 44px; } +} + +/* 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 { + 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: 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 (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. 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; } +} + +/* 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: 901px) { + .sidenav:hover, + .sidenav:focus-within { + position: fixed; + top: var(--topbar-h); + bottom: 0; + left: 0; + width: var(--sidenav-w); + z-index: 30; + /* 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; + } + .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; + /* 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; + } +} + +@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); } + .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/error-403.html b/modules/manager2/webapp/error-403.html new file mode 100644 index 000000000000..d09088cf682e --- /dev/null +++ b/modules/manager2/webapp/error-403.html @@ -0,0 +1,34 @@ + + + + + + + 403 · #{manager2.ui.brand.name} + + + + +
+

403

+

#{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 new file mode 100644 index 000000000000..6cc28be5218c --- /dev/null +++ b/modules/manager2/webapp/error-404.html @@ -0,0 +1,34 @@ + + + + + + + 404 · #{manager2.ui.brand.name} + + + + +
+

404

+

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

+

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

+ #{manager2.ui.error.back} +
+ + diff --git a/modules/manager2/webapp/images/favicon.ico b/modules/manager2/webapp/images/favicon.ico new file mode 100644 index 000000000000..6c5bd2c63b38 Binary files /dev/null and b/modules/manager2/webapp/images/favicon.ico differ 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 +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 + + + + + + + image/svg+xml + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +/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..1870f71cd4d5 --- /dev/null +++ b/modules/manager2/webapp/index.html @@ -0,0 +1,64 @@ + + + + + + + + #{manager2.ui.brand.name} + + + + + + +
+ + + + + diff --git a/modules/manager2/webapp/js/api.js b/modules/manager2/webapp/js/api.js new file mode 100644 index 000000000000..67c36915ea29 --- /dev/null +++ b/modules/manager2/webapp/js/api.js @@ -0,0 +1,186 @@ +/* + * 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(/\/$/, ''); + +import { t } from './i18n.js'; + +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(t('manager2.ui.api.csrfTokenMissing')); + } + 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/i18n.js b/modules/manager2/webapp/js/i18n.js new file mode 100644 index 000000000000..c9626df8c8d1 --- /dev/null +++ b/modules/manager2/webapp/js/i18n.js @@ -0,0 +1,92 @@ +/* + * 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. + */ + +// Client-side localization. The messages come from the LocalStrings bundle +// of the web application, served by the /i18n endpoint localized with the +// locales the browser sent in its Accept-Language header. + +import { BASE } from './api.js'; + +let messages = {}; + +// The locale the server resolved for the bundle ('en' when no translation +// is installed). Live ESM binding: importers see the update after +// loadMessages() completes. +export let locale = 'en'; + +/** + * Load the localized messages of the client. Must be awaited before the + * interface is rendered; rendering falls back to the keys when this fails. + */ +export async function loadMessages() { + try { + const resp = await fetch(BASE + '/i18n', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (resp.ok) { + const data = await resp.json(); + if (data && data.messages) { + messages = data.messages; + if (data.locale) { + locale = data.locale; + } + } + } + } catch (e) { + // Without the bundle the keys are displayed as-is. + } + document.documentElement.setAttribute('lang', locale); + return messages; +} + +/** + * Translate a message of the bundle. + * + * @param {string} key the bundle key, e.g. manager2.ui.apps.title + * @param {...*} args the values for the {0}, {1}, ... placeholders + * @returns {string} the localized message, or the key when it is unknown + */ +export function t(key, ...args) { + let text = messages[key]; + if (text === undefined) { + return key; + } + if (args.length > 0) { + text = text.replace(/\{(\d+)\}/g, (match, index) => + (args[Number(index)] !== undefined ? String(args[Number(index)]) : match)); + } + return text; +} + +/** + * Whether the loaded bundle contains a key. + * + * @param {string} key the bundle key + * @returns {boolean} true when the key is known + */ +export function has(key) { + return Object.prototype.hasOwnProperty.call(messages, key); +} + +/** + * A number formatter for the active locale (the base locale is a valid + * fallback: it renders exactly the English number format). + */ +export function numberFormatter(options) { + return new Intl.NumberFormat(locale, options); +} diff --git a/modules/manager2/webapp/js/logviewer.js b/modules/manager2/webapp/js/logviewer.js new file mode 100644 index 000000000000..a880c46cf6a8 --- /dev/null +++ b/modules/manager2/webapp/js/logviewer.js @@ -0,0 +1,411 @@ +/* + * 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, BASE } from './api.js'; +import { el, clear, table, drawer, formatBytes, formatMs } from './ui.js'; +import { t } from './i18n.js'; + +// Column definitions per field name. `render` receives the row and returns a +// node or a string. +const COLUMNS = { + time: { labelKey: 'manager2.ui.logviewer.col.time' }, + level: { + labelKey: 'manager2.ui.logviewer.col.level', + render: (r) => levelBadge(r.level), + }, + thread: { labelKey: 'manager2.ui.logviewer.col.thread' }, + source: { labelKey: 'manager2.ui.logviewer.col.source' }, + message: { labelKey: 'manager2.ui.logviewer.col.message', wide: true }, + raw: { labelKey: 'manager2.ui.logviewer.col.raw', wide: true }, + throwable: { labelKey: 'manager2.ui.logviewer.col.throwable', wide: true }, + method: { labelKey: 'manager2.ui.logviewer.col.method' }, + host: { labelKey: 'manager2.ui.col.host' }, + remoteAddr: { labelKey: 'manager2.ui.logviewer.col.remoteAddr' }, + localAddr: { labelKey: 'manager2.ui.logviewer.col.localAddr' }, + localServerName: { labelKey: 'manager2.ui.logviewer.col.server' }, + user: { labelKey: 'manager2.ui.logviewer.col.user' }, + path: { labelKey: 'manager2.ui.col.path' }, + query: { labelKey: 'manager2.ui.logviewer.col.query' }, + request: { labelKey: 'manager2.ui.logviewer.col.request', wide: true }, + protocol: { labelKey: 'manager2.ui.logviewer.col.protocol' }, + statusCode: { + labelKey: 'manager2.ui.logviewer.col.status', + numeric: true, + render: (r) => statusBadge(r.statusCode), + }, + size: { + labelKey: 'manager2.ui.logviewer.col.size', + numeric: true, + render: (r) => formatBytes(r.size), + }, + byteSentNC: { + labelKey: 'manager2.ui.logviewer.col.sizeNoContentLength', + numeric: true, + render: (r) => formatBytes(r.byteSentNC), + }, + elapsedTime: { + labelKey: 'manager2.ui.logviewer.col.elapsed', + numeric: true, + render: (r) => formatMs(r.elapsedTime), + }, + elapsedTimeS: { + labelKey: 'manager2.ui.logviewer.col.elapsedSeconds', + numeric: true, + }, + firstByteTime: { + labelKey: 'manager2.ui.logviewer.col.firstByte', + numeric: true, + render: (r) => formatMs(r.firstByteTime), + }, + port: { labelKey: 'manager2.ui.logviewer.col.port', numeric: true }, + sessionId: { labelKey: 'manager2.ui.logviewer.col.session', mono: true }, + threadName: { labelKey: 'manager2.ui.logviewer.col.thread' }, + connectionStatus: { labelKey: 'manager2.ui.logviewer.col.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) { + const def = COLUMNS[key]; + return def && def.labelKey ? t(def.labelKey) : 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 === 'logicalUserName') 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: t('manager2.ui.logviewer.recordTitle'), content: body }); +} + +// Build the column descriptors from the ordered field list the server sent. +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] || {}; + cols.push({ + key, + label: labelFor(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 + ? (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' }, + t('manager2.ui.common.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' }, + t('manager2.ui.common.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' }, + el('label', {}, t('manager2.ui.logviewer.file')), fileSelect); + const linesField = el('div', { class: 'field log-field' }, + el('label', {}, t('manager2.ui.logviewer.maxLines')), linesSelect); + const refreshField = el('div', { class: 'field log-field log-field-btn' }, + el('label', {}, '\u00a0'), + el('div', { class: 'log-btns' }, refreshBtn, downloadBtn)); + + 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: t('manager2.ui.logviewer.allSeverities') }] + .concat(levels.map((l) => ({ value: l, label: l }))); + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, t('manager2.ui.logviewer.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', {}, t('manager2.ui.logviewer.col.method')), + select([{ value: '', label: t('manager2.ui.common.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', {}, t('manager2.ui.logviewer.col.status')), + select( + [{ value: '', label: t('manager2.ui.common.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')) { + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, t('manager2.ui.logviewer.col.user')), + textInput(t('manager2.ui.logviewer.filterByUser'), (v) => { state.filters.user = v; loadFile(); }, f.user || ''))); + } + if (has(fields, 'sessionId')) { + filterHolder.append(el('div', { class: 'field log-field' }, + el('label', {}, t('manager2.ui.logviewer.sessionId')), + textInput(t('manager2.ui.logviewer.filterBySessionId'), (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', {}, t('manager2.ui.logviewer.search')), + textInput(t('manager2.ui.logviewer.searchPlaceholder'), (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: '' }, t('manager2.ui.logviewer.noFiles'))); + fileSelect.disabled = true; + downloadBtn.disabled = true; + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, + t(kind === 'log' ? 'manager2.ui.logviewer.noLogFiles' : 'manager2.ui.logviewer.noAccessLogFiles'))); + 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; + downloadBtn.disabled = !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': t('manager2.ui.common.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; + } + + // 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 || []; + let summary = data.matched === 1 + ? t('manager2.ui.logviewer.summarySingular', records.length, data.matched, data.total) + : t('manager2.ui.logviewer.summaryPlural', records.length, data.matched, data.total); + if (data.truncated) { + summary += ' - ' + t('manager2.ui.logviewer.truncated', + Math.round((data.readBytes / 1024 / 1024) * 10) / 10); + } + statusLine.textContent = summary; + + const cols = columnsFor(state.fields); + if (records.length === 0) { + clear(tableHolder); + tableHolder.append(el('div', { class: 'empty' }, + t('manager2.ui.logviewer.noMatches'))); + } else { + clear(tableHolder); + const tbl = table({ + columns: cols, + rows: records, + onRowClick: (row) => showRecord(row), + empty: t('manager2.ui.logviewer.noMatches'), + }); + tbl.classList.add('log-table'); + tableHolder.append(tbl); + } + 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..5f82a12ff6cb --- /dev/null +++ b/modules/manager2/webapp/js/main.js @@ -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. + */ + +import { BASE, get } from './api.js'; +import { t, loadMessages } from './i18n.js'; +import { register, setNotFound, render, setNavUpdater } from './router.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'; +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', labelKey: 'manager2.ui.nav.dashboard' }, + { route: '/apps', icon: 'apps', labelKey: 'manager2.ui.nav.apps' }, + { route: '/hosts', icon: 'hosts', labelKey: 'manager2.ui.nav.hosts' }, + { route: '/configuration', icon: 'config', labelKey: 'manager2.ui.nav.configuration' }, + { route: '/users', icon: 'users', labelKey: 'manager2.ui.nav.users' }, + { route: '/monitoring', icon: 'monitoring', labelKey: 'manager2.ui.nav.monitoring' }, + { route: '/diagnostics', icon: 'diagnostics', labelKey: 'manager2.ui.nav.diagnostics' }, + { route: '/logs', icon: 'logs', labelKey: 'manager2.ui.nav.logs' }, + { route: '/access-log', icon: 'access-log', labelKey: 'manager2.ui.nav.accessLog' }, +]; + +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 label = t(item.labelKey); + const btn = nav.querySelector('[data-nav="' + item.route + '"]'); + btn.innerHTML = ''; + // 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', label); + btn.append(icon(item.icon, 18)); + const labelNode = document.createElement('span'); + labelNode.className = 'nav-label'; + labelNode.textContent = label; + btn.append(labelNode); + 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': t('manager2.ui.common.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', {}, t('manager2.ui.common.errorTitle')), + el('p', { style: 'color:var(--text-soft);' }, err.message))); + } + container.focus({ preventScroll: true }); + }); +} + +// ============================ Boot ===================================== + +async function boot() { + await loadMessages(); + initTheme(); + 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', t('manager2.ui.shell.logout')); + 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' }); + } 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', {}, t('manager2.ui.common.notFoundPath', 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', {}, t('manager2.ui.shell.uptime', 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..d77a4fd08693 --- /dev/null +++ b/modules/manager2/webapp/js/pages/accesslog.js @@ -0,0 +1,29 @@ +/* + * 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'; +import { t } from '../i18n.js'; + +export async function accessLog(container) { + return logPage(container, { + kind: 'access', + title: t('manager2.ui.nav.accessLog'), + subtitle: t('manager2.ui.accesslog.subtitle'), + 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..e285b8c962bc --- /dev/null +++ b/modules/manager2/webapp/js/pages/apps.js @@ -0,0 +1,682 @@ +/* + * 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, actionMenu, + formatTimestamp, formatDuration } from '../ui.js'; +import { t } from '../i18n.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', {}, t('manager2.ui.nav.apps')), + el('p', {}, t('manager2.ui.apps.subtitle')), + el('span', { class: 'head-spacer' }), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => deployModal() }, + t('manager2.ui.apps.deployButton')))); + 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: t('manager2.ui.col.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: t('manager2.ui.apps.displayName'), muted: true }, + { key: 'version', label: t('manager2.ui.col.version'), muted: true, render: (a) => a.version || '-' }, + { key: 'state', label: t('manager2.ui.col.state'), render: (a) => stateBadge(a.available ? 'RUNNABLE' : 'STOPPED') }, + { key: 'sessions', label: t('manager2.ui.col.sessions'), numeric: true }, + { + key: 'docBase', label: t('manager2.ui.apps.docBase'), muted: true, + render: (a) => el('code', {}, a.docBase || '-'), + }, + { + key: 'actions', label: t('manager2.ui.col.actions'), + render: (a) => actionMenu([ + a.available + ? { label: t('manager2.ui.common.stop'), onclick: () => lifecycle(a, 'stop') } + : { label: t('manager2.ui.common.start'), class: 'btn-primary', onclick: () => lifecycle(a, 'start') }, + { label: t('manager2.ui.common.reload'), disabled: !a.available, onclick: () => lifecycle(a, 'reload') }, + { + label: t('manager2.ui.apps.undeploy'), class: 'btn-danger', disabled: a.self, + title: a.self ? t('manager2.ui.apps.cannotUndeploySelf') : t('manager2.ui.apps.undeploy'), + onclick: () => undeploy(a), + }, + ]), + }], + rows: data.apps, + onRowClick: (a) => { + window.history.pushState({}, '', BASE + appUrl(a.host, a.path)); + window.dispatchEvent(new PopStateEvent('popstate')); + }, + empty: t('manager2.ui.apps.noApps'), + stackable: true, + })); + } + + await load(); + return null; +} + +async function lifecycle(app, action) { + const ok = action === 'start' + ? true + : await confirm({ + title: action === 'stop' ? t('manager2.ui.apps.stopConfirmTitle') : t('manager2.ui.apps.reloadConfirmTitle'), + message: t('manager2.ui.apps.stopOrReloadConfirm', app.path === '' ? '/' : app.path), + confirmLabel: action === 'stop' ? t('manager2.ui.common.stop') : t('manager2.ui.common.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: t('manager2.ui.apps.undeployConfirmTitle'), + message: t('manager2.ui.apps.undeployConfirm', app.path === '' ? '/' : app.path), + confirmLabel: t('manager2.ui.apps.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', {}, t('manager2.ui.apps.warFile')), + el('input', { type: 'file', accept: '.war', id: 'deploy-war' })), + el('div', { class: 'form-grid' }, + el('div', { class: 'field' }, + el('label', {}, t('manager2.ui.apps.contextPathOptional')), + el('input', { type: 'text', id: 'deploy-path', placeholder: '/myapp' }), + el('span', { class: 'hint' }, t('manager2.ui.apps.contextPathHint'))), + el('div', { class: 'field' }, + el('label', {}, t('manager2.ui.apps.versionOptional')), + 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', {}, t('manager2.ui.apps.contextPath')), + el('input', { type: 'text', id: 'srv-path', placeholder: '/myapp' })), + el('div', { class: 'field' }, + el('label', {}, t('manager2.ui.apps.versionOptional')), + el('input', { type: 'text', id: 'srv-version' })), + el('div', { class: 'field span-2' }, + el('label', {}, t('manager2.ui.apps.xmlConfigOptional')), + el('input', { type: 'text', id: 'srv-config', placeholder: 'http://.../context.xml' })), + el('div', { class: 'field span-2' }, + el('label', {}, t('manager2.ui.apps.warLocationOptional')), + 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' }), + t('manager2.ui.apps.replaceExisting'))))); + + const tabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active', onclick: () => switchTab('upload') }, t('manager2.ui.apps.tabUpload')), + el('button', { type: 'button', class: 'tab', onclick: () => switchTab('server') }, t('manager2.ui.apps.tabServer'))); + + function switchTab(name) { + tab = name; + tabs.querySelectorAll('.tab').forEach((tab, i) => { + tab.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: t('manager2.ui.apps.deployTitle'), + wide: true, + content: el('div', {}, + tabs, + uploadPane, + serverPane, + progress), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.apps.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(t('manager2.ui.apps.selectWarFirst'), '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) || t('manager2.ui.apps.uploadFailedStatus', xhr.status)); + err.status = xhr.status; + reject(err); + } + }); + xhr.addEventListener('error', () => reject(new Error(t('manager2.ui.apps.uploadNetworkError')))); + 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')); + } }, t('manager2.ui.nav.apps')), + ' / ', + 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' }, t('manager2.ui.apps.tabOverview')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.apps.tabSessions')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.apps.tabMetrics'))); + 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((tab, i) => tab.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((tab, i) => tab.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' }, t('manager2.ui.apps.notFound'))); + 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') }, t('manager2.ui.common.stop')) + : el('button', { type: 'button', class: 'btn btn-primary', onclick: () => lifecycle(app, 'start') }, t('manager2.ui.common.start')), + el('button', { type: 'button', class: 'btn', disabled: !available, onclick: () => lifecycle(app, 'reload') }, t('manager2.ui.common.reload')), + el('button', { + type: 'button', class: 'btn btn-danger', disabled: app.self, + title: app.self ? t('manager2.ui.apps.cannotUndeploySelf') : t('manager2.ui.apps.undeploy'), + onclick: () => undeploy(app), + }, t('manager2.ui.apps.undeploy'))); + + const dl = el('dl', { class: 'kv' }, + kv(t('manager2.ui.col.host'), host), + kv(t('manager2.ui.col.path'), contextPath === '' ? '/' : contextPath), + kv(t('manager2.ui.apps.displayName'), app.displayName || '-'), + kv(t('manager2.ui.col.version'), app.version || '-'), + kv(t('manager2.ui.apps.docBase'), el('code', {}, app.docBase || '-')), + kv(t('manager2.ui.apps.sessionTimeout'), app.sessionTimeout != null ? t('manager2.ui.apps.minutes', app.sessionTimeout) : '-'), + kv(t('manager2.ui.dashboard.kpi.activeSessions'), String(app.sessions))); + + pane.append(el('div', { class: 'card' }, + el('h3', {}, t('manager2.ui.apps.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: t('manager2.ui.apps.idleSecondsPlaceholder'), + class: 'expire-idle-input', + }), + 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(t('manager2.ui.apps.idleTimeoutNeeded'), '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'); + } + }, + }, t('manager2.ui.apps.expireIdle')), + el('span', { class: 'head-spacer' }), + 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(t('manager2.ui.apps.selectSessions'), 'warn'); + return; + } + const ok = await confirm({ + title: t('manager2.ui.apps.invalidateSessionsTitle'), + message: t('manager2.ui.apps.invalidateSessionsConfirm', selected.length), + confirmLabel: t('manager2.ui.apps.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'); + } + }, + }, t('manager2.ui.apps.invalidateSelected'))); + 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: t('manager2.ui.apps.colId'), sortable: true, + render: (s) => el('code', {}, s.id), + }, + { + key: 'user', label: t('manager2.ui.col.user'), sortable: true, + render: (s) => s.user || '-', + }, + { + key: 'creationTime', label: t('manager2.ui.apps.colCreated'), sortable: true, + render: (s) => formatTimestamp(s.creationTime), + }, + { + key: 'lastAccessedTime', label: t('manager2.ui.apps.colLastAccessed'), sortable: true, + render: (s) => formatTimestamp(s.lastAccessedTime), + }, + { + key: 'maxInactiveInterval', label: t('manager2.ui.apps.colTimeout'), sortable: true, numeric: true, + }, + { + key: 'active', label: t('manager2.ui.col.state'), + render: (s) => el('span', { class: 'badge ' + (s.active ? 'ok' : 'stop') }, + s.active ? t('manager2.ui.apps.sessionActive') : t('manager2.ui.apps.sessionProxy')), + }, + ], + 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: t('manager2.ui.apps.noSessions'), + stackable: true, + })); + } + + function sessionDrawer(session) { + const body = el('div', {}, + el('dl', { class: 'kv', style: 'margin-bottom:20px;' }, + el('dt', {}, t('manager2.ui.apps.colId')), el('dd', {}, el('code', {}, session.id)), + el('dt', {}, t('manager2.ui.col.user')), el('dd', {}, session.user || '-'), + el('dt', {}, t('manager2.ui.apps.colLocale')), el('dd', {}, session.locale || '-'), + el('dt', {}, t('manager2.ui.apps.colCreated')), el('dd', {}, formatTimestamp(session.creationTime)), + el('dt', {}, t('manager2.ui.apps.colLastAccessed')), el('dd', {}, formatTimestamp(session.lastAccessedTime)), + el('dt', {}, t('manager2.ui.apps.timeoutLabel')), el('dd', {}, t('manager2.ui.apps.seconds', session.maxInactiveInterval)))); + + 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: t('manager2.ui.apps.invalidateSessionTitle'), + message: t('manager2.ui.apps.invalidateSessionConfirm', session.id), + confirmLabel: t('manager2.ui.apps.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'); + } + }, + }, t('manager2.ui.apps.invalidateSession'))); + body.append(actions, el('h3', {}, t('manager2.ui.apps.attributes')), el('div', { id: 'attr-holder' })); + + const close = drawer({ title: t('manager2.ui.apps.sessionTitle', 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' }, t('manager2.ui.apps.noAttributes'))); + return; + } + holder.append(el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, t('manager2.ui.col.name')), + el('th', {}, t('manager2.ui.apps.colClass')), + el('th', {}, t('manager2.ui.apps.colValue')), + 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: t('manager2.ui.apps.removeAttributeTitle'), + message: t('manager2.ui.apps.removeAttributeConfirm', a.name), + confirmLabel: t('manager2.ui.common.remove'), + danger: true, + }); + if (!ok) return; + try { + await api('DELETE', '/api/apps/' + seg + '/sessions/' + + encodeURIComponent(session.id) + '/attributes/' + + encodeURIComponent(a.name) + query); + toast(t('manager2.ui.apps.attributeRemoved'), 'ok'); + close(); + loadSessions(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, t('manager2.ui.common.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(t('manager2.ui.dashboard.kpi.activeSessions'), m.activeSessions != null ? String(m.activeSessions) : '-'), + kpi(t('manager2.ui.apps.expiredSessions'), m.expiredSessions != null ? String(m.expiredSessions) : '-'), + kpi(t('manager2.ui.apps.avgSessionLifetime'), m.sessionAverageAliveTime != null ? + formatDuration(m.sessionAverageAliveTime) : '-'), + kpi(t('manager2.ui.apps.maxSessionLife'), m.sessionMaxAliveTime != null ? + formatDuration(m.sessionMaxAliveTime) : '-'))); + + if (data.jsp) { + pane.append(el('div', { class: 'card' }, + el('h3', {}, t('manager2.ui.apps.jsps')), + el('dl', { class: 'kv' }, + el('dt', {}, t('manager2.ui.apps.jspFiles')), el('dd', {}, String(data.jsp.jspCount)), + el('dt', {}, t('manager2.ui.apps.reloads')), el('dd', {}, String(data.jsp.jspReloadCount))))); + } + + pane.append(el('div', { class: 'card' }, + el('h3', {}, t('manager2.ui.apps.servlets')), + el('div', { class: 'table-wrap stackable' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, t('manager2.ui.col.name')), + el('th', {}, t('manager2.ui.apps.mappings')), + el('th', { class: 'num' }, t('manager2.ui.col.requests')), + el('th', { class: 'num' }, t('manager2.ui.col.errors')), + el('th', { class: 'num' }, t('manager2.ui.monitoring.col.processingTime')), + el('th', { class: 'num' }, t('manager2.ui.apps.colMaxTime')))), + el('tbody', {}, (data.wrappers || []).map((w) => el('tr', {}, + el('td', { 'data-label': t('manager2.ui.col.name') }, el('code', {}, w.name)), + el('td', { class: 'muted', 'data-label': t('manager2.ui.apps.mappings') }, (w.mappings || []).join(', ')), + el('td', { class: 'num', 'data-label': t('manager2.ui.col.requests') }, String(w.requestCount)), + el('td', { class: 'num', 'data-label': t('manager2.ui.col.errors') }, String(w.errorCount)), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.processingTime') }, formatDuration(w.processingTime)), + el('td', { class: 'num', 'data-label': t('manager2.ui.apps.colMaxTime') }, 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..e0423409860d --- /dev/null +++ b/modules/manager2/webapp/js/pages/configuration.js @@ -0,0 +1,1166 @@ +/* + * 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, actionMenu } from '../ui.js'; +import { t, has } from '../i18n.js'; + +// Localized display name of a component type: falls back to the raw type +// identifier when the bundle has no label for it. +function typeLabel(type) { + const key = 'manager2.ui.type.' + type; + return has(key) ? t(key) : type; +} + +// Build a localized message with inline code spans (the {0}, ... +// placeholders are replaced by the given code values). +function codeNodes(key, codes) { + const message = t(key); + const nodes = []; + const regex = /\{(\d+)\}/g; + let last = 0; + let m; + while ((m = regex.exec(message))) { + if (m.index > last) { + nodes.push(document.createTextNode(message.substring(last, m.index))); + } + nodes.push(el('code', {}, codes[Number(m[1])])); + last = regex.lastIndex; + } + if (last < message.length) { + nodes.push(document.createTextNode(message.substring(last))); + } + return nodes; +} + +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', 'upgradeProtocol'], + sslHostConfig: ['certificate'], + certificate: [], + upgradeProtocol: [], + 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', {}, t('manager2.ui.nav.configuration')), + el('p', {}, t('manager2.ui.config.subtitle')), + el('span', { class: 'head-spacer' }), + el('button', { type: 'button', class: 'btn', onclick: reloadAll }, t('manager2.ui.common.reload')), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => saveToServerXml() }, t('manager2.ui.config.save')))); + view.append(el('div', { class: 'config-split' }, + el('div', { class: 'card config-tree-card' }, el('div', { class: 'card-title-row' }, + el('h3', {}, t('manager2.ui.config.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': t('manager2.ui.common.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); + + // 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 ? t('manager2.ui.config.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 }, typeLabel(node.type)), + el('span', { class: 'config-node-name' }, node.name || t('manager2.ui.config.unnamed')), + node.self ? el('span', { class: 'badge plain', style: 'margin-left:6px' }, t('manager2.ui.config.thisApp')) : '', + 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': t('manager2.ui.common.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(); + // 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: 900px)').matches) { + detailCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + function renderDetail() { + const d = selectedDetail; + clear(detailCard); + + const actions = []; + if (addable(d)) { + actions.push({ label: t('manager2.ui.config.addChild'), onclick: () => addChildModal(d) }); + } + if (d.type !== 'server') { + actions.push({ + label: t('manager2.ui.common.remove'), class: 'btn-danger', disabled: d.self, + title: d.self ? t('manager2.ui.config.cannotRemoveSelf') : t('manager2.ui.common.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 }, typeLabel(d.type)), + el('h3', {}, d.name || t('manager2.ui.config.unnamed')), + d.state ? stateBadge(d.state) : ''), + actionMenu(actions)); + 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', {}, t('manager2.ui.config.properties')), + naming ? el('button', { + type: 'button', class: 'btn btn-sm', + onclick: () => paramForm(head, d), + }, t('manager2.ui.config.addParameter')) : null); + const body = rows.length + ? el('div', { class: 'config-props' }, rows) + : el('p', { class: 'muted' }, t('manager2.ui.config.noParameters')); + detailCard.append(head, body); + } else { + detailCard.append(el('p', { class: 'muted' }, t('manager2.ui.config.noProperties'))); + } + + const kids = d.children || []; + if (kids.length) { + detailCard.append(el('h4', {}, t('manager2.ui.config.children')), + el('div', { class: 'config-children' }, kids.map((k) => el('button', { + type: 'button', class: 'config-child-chip', onclick: () => selectNode(k.id), + }, typeLabel(k.type) + ': ' + (k.name || t('manager2.ui.config.unnamed')))))); + } + } + + function propRow(node, p) { + const label = el('label', { class: 'config-prop-name' }, p.name, + p.param ? el('span', { class: 'config-param-hint' }, t('manager2.ui.config.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), + }, t('manager2.ui.common.apply'))); + if (p.param) { + if (paramSet(p)) { + edit.append(el('button', { + type: 'button', class: 'btn btn-sm btn-danger', + title: t('manager2.ui.config.removeParameter'), + onclick: () => removeParameter(node, p), + }, t('manager2.ui.common.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' }, t('manager2.ui.config.notSet'))); + } + } + 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 ptype = p.type; + if (ptype === 'boolean') { + const box = el('input', { type: 'checkbox', class: 'config-check' }); + // 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(ptype)) { + return el('input', { type: 'text', inputmode: 'numeric', value: p.value == null ? '' : String(p.value), class: 'config-input num' }); + } + if (ptype === '[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: t('manager2.ui.config.commaSeparated') }); + } + if (ptype === '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 ptype = p.type; + if (ptype === 'boolean') return input.checked; + if (NUMERIC_TYPES.has(ptype)) return input.value.trim(); + if (ptype === '[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. 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(t('manager2.ui.config.noChanges'), 'info'); + return; + } + if (RISKY_ATTRIBUTES.has(p.name)) { + const ok = await confirm({ + title: t('manager2.ui.config.changeTitle', p.name), + message: t('manager2.ui.config.changeMessage', p.name, node.name || typeLabel(node.type)), + confirmLabel: t('manager2.ui.config.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: t('manager2.ui.config.paramNamePlaceholder') }); + const valueInput = el('input', { type: 'text', class: 'config-input', placeholder: t('manager2.ui.config.valuePlaceholder') }); + 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), + }, t('manager2.ui.common.add')), + el('button', { type: 'button', class: 'btn btn-sm', onclick: () => form.remove() }, t('manager2.ui.common.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(t('manager2.ui.config.paramNameRequired'), '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 (!paramSet(p)) { + toast(t('manager2.ui.config.paramNotSet'), '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((ty) => el('option', { value: ty }, typeLabel(ty)))); + const fieldsWrap = el('div', { class: 'form-grid' }); + const noteWrap = el('div', {}); + + function currentCtx(type) { + if (type !== 'resource') return {}; + const typeEl = document.getElementById('c-type'); + const factoryEl = document.getElementById('c-factory'); + return { jndiType: typeEl ? typeEl.value.trim() : '', factory: factoryEl ? factoryEl.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' }, + t('manager2.ui.config.replaceNote', typeLabel(type), current.name || current.className))); + } + } + if (type === 'resource') { + noteWrap.append(el('p', { class: 'muted' }, + t('manager2.ui.config.factoryOptionsNote'))); + } + if (type === 'upgradeProtocol') { + noteWrap.append(el('p', { class: 'muted' }, + t('manager2.ui.config.upgradeProtocolNote'))); + } + } + // 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', {}, t('manager2.ui.config.componentType')), select), + fieldsWrap, + noteWrap); + + modal({ + title: t('manager2.ui.config.addChildTitle', typeLabel(parent.type)), + content: body, + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.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 [ + [t('manager2.ui.config.certType'), select('c-cert-type', CERT_TYPES)], + [t('manager2.ui.config.keystoreFile'), input('c-cert-file', 'text', 'conf/keystore.p12'), true], + [t('manager2.ui.config.keystorePassword'), input('c-cert-pass', 'password', 'changeit')], + [t('manager2.ui.config.keyAlias'), input('c-cert-alias', 'text', 'tomcat')], + [t('manager2.ui.config.keystoreType'), 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 [[t('manager2.ui.config.name'), input('c-name', 'text', 'Catalina2')]]; + case 'resource': { + const fields = [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'jdbc/MyDB')], + [t('manager2.ui.config.typeLabel'), input('c-type', 'text', 'javax.sql.DataSource')], + [t('manager2.ui.config.factory'), input('c-factory', 'text', BASIC_DATA_SOURCE_FACTORY), true], + [t('manager2.ui.config.auth'), input('c-auth', 'text', 'Container')], + ]; + fields.push(...resourceOptionFields(ctx && ctx.jndiType, ctx && ctx.factory).fields); + return fields; + } + case 'resourceLink': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'jdbc/MyDB')], + [t('manager2.ui.config.typeLabel'), input('c-type', 'text', 'javax.sql.DataSource')], + [t('manager2.ui.config.globalJndiName'), input('c-global', 'text', 'jdbc/MyGlobalDB')], + [t('manager2.ui.config.factory'), input('c-factory', 'text', ''), true], + ]; + case 'resourceEnvRef': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'jdbc/MyDB')], + [t('manager2.ui.config.typeLabel'), input('c-type', 'text', 'javax.sql.DataSource')], + ]; + case 'environment': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'mail/Session')], + [t('manager2.ui.config.typeLabel'), input('c-type', 'text', 'java.lang.String')], + [t('manager2.ui.config.valueLabel'), input('c-value', 'text', '')], + ]; + case 'ejb': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'ejb/MyBean')], + [t('manager2.ui.config.typeHomeInterface'), input('c-type', 'text', 'org.example.MyBeanHome')], + [t('manager2.ui.config.link'), input('c-link', 'text', '')], + ]; + case 'localEjb': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'ejb/MyBean')], + [t('manager2.ui.config.typeLocalHome'), input('c-type', 'text', 'org.example.MyBeanLocalHome')], + [t('manager2.ui.config.typeLocalInterface'), input('c-local', 'text', '')], + [t('manager2.ui.config.link'), input('c-link', 'text', '')], + ]; + case 'serviceRef': + return [ + [t('manager2.ui.config.jndiName'), input('c-name', 'text', 'service/MyService')], + [t('manager2.ui.config.typeServiceInterface'), input('c-type', 'text', 'org.example.MyService')], + [t('manager2.ui.apps.displayName'), input('c-displayname', 'text', '')], + ]; + case 'host': + return [ + [t('manager2.ui.config.name'), input('c-name', 'text', 'example.com')], + [t('manager2.ui.hosts.aliasesLabel'), input('c-aliases', 'text', 'www.example.com')], + [t('manager2.ui.hosts.appBase'), input('c-appbase', 'text', 'webapps/example.com'), true], + ]; + case 'context': + return [ + [t('manager2.ui.config.path'), input('c-path', 'text', '/myapp')], + [t('manager2.ui.apps.displayName'), input('c-display', 'text', '')], + [t('manager2.ui.apps.docBase'), input('c-docbase', 'text', t('manager2.ui.config.docBasePlaceholder')), true], + ]; + case 'wrapper': + return [ + [t('manager2.ui.config.name'), input('c-name', 'text', 'myservlet')], + [t('manager2.ui.config.servletClass'), input('c-servlet', 'text', 'org.example.MyServlet'), true], + [t('manager2.ui.config.urlPatterns'), input('c-urlpats', 'text', '/hello, /hi'), true], + ]; + case 'valve': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.valves.AccessLogValve'), true]]; + case 'listener': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.mbeans.GlobalResourcesLifecycleListener'), true]]; + case 'cluster': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.ha.tcp.SimpleTcpCluster'), true]]; + case 'clusterValve': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.ha.tcp.ReplicationValve'), true]]; + case 'channel': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.group.GroupChannel'), true]]; + case 'membership': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.membership.McastService'), true]]; + case 'sender': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.transport.ReplicationTransmitter'), true]]; + case 'receiver': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.transport.nio.NioReceiver'), true]]; + case 'interceptor': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor'), true]]; + case 'deployer': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.ha.deploy.FarmWarDeployer'), true]]; + case 'clusterManager': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.ha.session.DeltaManager'), true]]; + case 'transport': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.tribes.transport.nio.PooledParallelSender'), true]]; + case 'clusterListener': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.ha.session.ClusterSessionListener'), true]]; + case 'realm': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.realm.UserDatabaseRealm'), true]]; + case 'manager': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.session.StandardManager'), true]]; + case 'sessionIdGenerator': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.util.StandardSessionIdGenerator'), true]]; + case 'resources': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.webresources.StandardRoot'), true]]; + case 'loader': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.catalina.loader.WebappLoader'), true]]; + case 'cookieProcessor': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.tomcat.util.http.Rfc6265CookieProcessor'), true]]; + case 'connector': + return [ + [t('manager2.ui.config.protocol'), input('c-protocol', 'text', 'HTTP/1.1')], + [t('manager2.ui.config.port'), input('c-port', 'text', '8081')], + ]; + case 'executor': + return [ + [t('manager2.ui.config.name'), input('c-name', 'text', 'tomcatThreadPool')], + [t('manager2.ui.config.maxThreads'), input('c-maxthreads', 'text', '150')], + [t('manager2.ui.config.minSpareThreads'), input('c-minspare', 'text', '4')], + ]; + case 'alias': + return [[t('manager2.ui.config.alias'), input('c-alias', 'text', 'www.example.com')]]; + case 'sslHostConfig': + return [ + [t('manager2.ui.config.hostName'), input('c-hostname', 'text', '_default_'), true], + ...certificateFields(), + ]; + case 'certificate': + return certificateFields(); + case 'upgradeProtocol': + return [[t('manager2.ui.config.className'), input('c-class', 'text', 'org.apache.coyote.http2.Http2Protocol'), true]]; + 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(); + case 'upgradeProtocol': return [['className', 'c-class']]; + 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: t('manager2.ui.config.removeTitle', typeLabel(d.type)), + message: t('manager2.ui.config.removeMessage', label), + confirmLabel: t('manager2.ui.common.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', {}, t('manager2.ui.config.selectComponent')))); + } + + // ============================ 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 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. + // 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 = t('manager2.ui.config.selfImpactTitle'); + return [ + { + label: t('manager2.ui.common.start'), + disabled: running || selfImpact, + title: selfImpact ? selfImpactTitle : t('manager2.ui.common.start'), + onclick: () => lifecycleOp(d, 'start'), + }, + { + label: t('manager2.ui.common.stop'), + disabled: !running || selfImpact, + title: selfImpact ? selfImpactTitle : t('manager2.ui.common.stop'), + onclick: () => lifecycleOp(d, 'stop'), + }, + { + label: t('manager2.ui.common.restart'), + title: t('manager2.ui.config.restartHelp'), + onclick: () => lifecycleOp(d, '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: t('manager2.ui.config.stopTitle', typeLabel(d.type)), + message: t('manager2.ui.config.stopMessage', label), + confirmLabel: t('manager2.ui.common.stop'), + danger: false, + }); + } else { + ok = await confirm({ + title: t('manager2.ui.config.restartTitle', typeLabel(d.type)), + message: d.affectsSelf + ? t('manager2.ui.config.restartMessageSelf', label) + : t('manager2.ui.config.restartMessage', label), + confirmLabel: t('manager2.ui.common.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(t('manager2.ui.config.reconnecting'), 'info', 8000); + for (let attempt = 0; attempt < 10; attempt++) { + await sleep(1000); + try { + await api('GET', '/api/csrf'); + toast(t('manager2.ui.config.reconnected'), '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(t('manager2.ui.config.reconnectFailed'), 'error', 10000); + } + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + // ============================ 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)' }, + t('manager2.ui.config.storeWarning'))); + if (files.length) { + body.append(el('p', { style: 'color:var(--text-soft)' }, + t('manager2.ui.config.storeFiles')), + 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', {}, ...codeNodes('manager2.ui.confirm.typeToConfirm', ['server.xml'])), input), + pre); + if (data.restartsManager) { + body.append(el('div', { class: 'config-store-warning' }, + el('strong', {}, t('manager2.ui.config.warning')), + t('manager2.ui.config.storeManagerWarning'))); + } + modal({ + title: t('manager2.ui.config.save'), + wide: true, + content: body, + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.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..06d4c0566077 --- /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 { t } from '../i18n.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', {}, t('manager2.ui.dashboard.title')), + 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', t('manager2.ui.dashboard.kpi.heapUsed')); + kpi('threads', t('manager2.ui.dashboard.kpi.busyThreads')); + kpi('sessions', t('manager2.ui.dashboard.kpi.activeSessions')); + kpi('rps', t('manager2.ui.dashboard.kpi.requestsPerSecond')); + kpi('errors', t('manager2.ui.dashboard.kpi.errorsPerSecond')); + view.append(kpiGrid); + + // ---------------- Charts ---------------- + const grid = el('div', { class: 'grid charts' }); + const heapCard = chartCard(t('manager2.ui.dashboard.chart.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) }), t('manager2.ui.dashboard.legend.used')), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(1) }), t('manager2.ui.dashboard.legend.committed')))); + + const threadsCard = chartCard(t('manager2.ui.dashboard.chart.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(t('manager2.ui.dashboard.chart.requestRate'), '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) }), t('manager2.ui.dashboard.legend.requestsPerSecond')), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(3) }), t('manager2.ui.dashboard.legend.errorsPerSecond')))); + + const bytesCard = chartCard(t('manager2.ui.dashboard.chart.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) }), t('manager2.ui.dashboard.legend.sentPerSecond')), + el('span', {}, el('span', { class: 'swatch', style: 'background:' + palette(5) }), t('manager2.ui.dashboard.legend.receivedPerSecond')))); + + 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' }), t('manager2.ui.nav.apps'))), + 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 = t('manager2.ui.dashboard.subtitle', + formatDuration(data.windowMs), data.tickMs / 1000); + 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 = t('manager2.ui.dashboard.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', {}, t('manager2.ui.col.path')), + el('th', {}, t('manager2.ui.col.state')), + el('th', {}, t('manager2.ui.col.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..775c7acc70fe --- /dev/null +++ b/modules/manager2/webapp/js/pages/diagnostics.js @@ -0,0 +1,262 @@ +/* + * 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'; +import { t } from '../i18n.js'; + +export async function diagnostics(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, t('manager2.ui.nav.diagnostics')), + el('p', {}, t('manager2.ui.diagnostics.subtitle')))); + container.append(view); + + const tabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active' }, t('manager2.ui.diagnostics.tab.ssl')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.diagnostics.tab.leaks')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.diagnostics.tab.jndi')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.diagnostics.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((tab, i) => tab.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((tab, i) => tab.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: t('manager2.ui.diagnostics.tlsHostPlaceholder'), class: 'tls-host', id: 'tls-host', + }), + el('button', { + type: 'button', class: 'btn btn-sm', + onclick: async () => { + const ok = await confirm({ + title: t('manager2.ui.diagnostics.sslReloadTitle'), + message: t('manager2.ui.diagnostics.sslReloadConfirm'), + confirmLabel: t('manager2.ui.diagnostics.sslReloadConfirmButton'), + }); + 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'); + } + }, + }, t('manager2.ui.diagnostics.sslReloadButton'))); + const list = el('div'); + pane.append(reloadRow, list); + + const subTabs = el('div', { class: 'tabs' }, + el('button', { type: 'button', class: 'tab active' }, t('manager2.ui.diagnostics.cipherSuites')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.diagnostics.certificates')), + el('button', { type: 'button', class: 'tab' }, t('manager2.ui.diagnostics.trustedCertificates'))); + 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' }, + t('manager2.ui.diagnostics.noSslConnector'))); + 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;' }, + t('manager2.ui.diagnostics.leaksDescription')), + 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(t('manager2.ui.diagnostics.leaksChecking'))); + try { + const data = await api('GET', '/api/leaks'); + clear(holder); + if (!data.leaks || data.leaks.length === 0) { + holder.append(el('div', { class: 'empty' }, t('manager2.ui.diagnostics.leaksNone'))); + } 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; + } + }, + }, t('manager2.ui.diagnostics.leaksCheck'))), + 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', class: 'res-type' }, + el('option', { value: '' }, t('manager2.ui.diagnostics.allTypes')), + 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(), + }, t('manager2.ui.common.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' }, t('manager2.ui.diagnostics.noResources'))); + return; + } + holder.append(el('div', { class: 'table-wrap' }, + el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, el('th', {}, t('manager2.ui.col.name')), el('th', {}, t('manager2.ui.diagnostics.colClass')))), + 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;' }, + t('manager2.ui.diagnostics.jvmDescription')), + el('div', { class: 'row-actions' }, + el('button', { + type: 'button', class: 'btn', + onclick: (e) => show('info', e.currentTarget), + }, t('manager2.ui.diagnostics.vmInfo')), + el('button', { + type: 'button', class: 'btn', + onclick: (e) => show('threaddump', e.currentTarget), + }, t('manager2.ui.diagnostics.threadDump'))), + holder)); + + async function show(which, btn) { + const title = which === 'info' ? t('manager2.ui.diagnostics.vmInfo') : t('manager2.ui.diagnostics.threadDump'); + 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 || t('manager2.ui.common.empty'))); + } catch (err) { + clear(body); + body.append(el('div', { class: 'empty' }, err.message)); + } finally { + btn.disabled = false; + } + } + } + + loaded[0] = true; + 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..43e7e2327974 --- /dev/null +++ b/modules/manager2/webapp/js/pages/hosts.js @@ -0,0 +1,210 @@ +/* + * 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, actionMenu } from '../ui.js'; +import { t } from '../i18n.js'; + +export async function hosts(container) { + const view = el('div', {}, + el('div', { class: 'page-head' }, + el('h1', {}, t('manager2.ui.hosts.title')), + el('p', {}, t('manager2.ui.hosts.subtitle')), + el('span', { class: 'head-spacer' }), + el('button', { type: 'button', class: 'btn', onclick: () => persist() }, t('manager2.ui.hosts.save')), + el('button', { type: 'button', class: 'btn btn-primary', onclick: () => addHostModal() }, t('manager2.ui.hosts.add')))); + 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: t('manager2.ui.col.name') }, + { key: 'aliases', label: t('manager2.ui.col.aliases'), muted: true, render: (h) => h.aliases.join(', ') || '-' }, + { + key: 'appBase', label: t('manager2.ui.hosts.appBase'), muted: true, + render: (h) => el('code', {}, h.appBase || '-'), + }, + { + key: 'state', label: t('manager2.ui.col.state'), + render: (h) => el('span', { class: 'badge ' + (h.started ? 'ok' : 'stop') }, + h.started ? t('manager2.ui.state.running') : t('manager2.ui.state.stopped')), + }, + { + key: 'self', label: t('manager2.ui.hosts.selfColumn'), + render: (h) => h.self + ? el('span', { class: 'badge plain' }, t('manager2.ui.hosts.thisHost')) + : el('span', { class: 'muted' }, '-'), + }, + { + key: 'actions', label: t('manager2.ui.col.actions'), + render: (h) => actionMenu([ + h.started + ? { label: t('manager2.ui.common.stop'), disabled: h.self, onclick: () => startStop(h, 'stop') } + : { label: t('manager2.ui.common.start'), class: 'btn-primary', disabled: h.self, onclick: () => startStop(h, 'start') }, + { + label: t('manager2.ui.common.remove'), class: 'btn-danger', disabled: h.self, + title: h.self ? t('manager2.ui.hosts.cannotRemoveSelf') : t('manager2.ui.common.remove'), + onclick: () => removeHost(h), + }, + ]), + }, + ], + rows: data, + empty: t('manager2.ui.hosts.empty'), + stackable: true, + })); + } + + async function startStop(host, action) { + const ok = action === 'stop' + ? await confirm({ + title: t('manager2.ui.hosts.stopConfirmTitle'), + message: t('manager2.ui.hosts.stopConfirm', host.name), + confirmLabel: t('manager2.ui.common.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: t('manager2.ui.hosts.removeConfirmTitle'), + message: t('manager2.ui.hosts.removeConfirm', host.name), + confirmLabel: t('manager2.ui.common.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(t('manager2.ui.col.name'), el('input', { type: 'text', id: 'h-name', required: true, placeholder: t('manager2.ui.hosts.namePlaceholder') })), + field(t('manager2.ui.hosts.aliasesLabel'), el('input', { type: 'text', id: 'h-aliases', placeholder: t('manager2.ui.hosts.aliasesPlaceholder') })), + fieldSpan2(t('manager2.ui.hosts.appBase'), el('input', { type: 'text', id: 'h-appbase', placeholder: '${catalina.base}/webapps' })), + field(t('manager2.ui.hosts.managerWebapp'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-manager' }), + t('manager2.ui.hosts.managerWebappHelp'))), + field(t('manager2.ui.hosts.autoDeploy'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-autodeploy', checked: true }), + t('manager2.ui.hosts.autoDeploy'))), + field(t('manager2.ui.hosts.deployOnStartup'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-deployonstartup', checked: true }), + t('manager2.ui.hosts.deployOnStartup'))), + field(t('manager2.ui.hosts.deployXml'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-deployxml', checked: true }), + t('manager2.ui.hosts.deployXml'))), + field(t('manager2.ui.hosts.unpackWars'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-unpackwars', checked: true }), + t('manager2.ui.hosts.unpackWars'))), + fieldSpan2(t('manager2.ui.hosts.copyXml'), + el('label', { class: 'check' }, + el('input', { type: 'checkbox', id: 'h-copyxml' }), + t('manager2.ui.hosts.copyXmlHelp'))))); + + modal({ + title: t('manager2.ui.hosts.addTitle'), + content: body, + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.add'), + class: 'btn-primary', + onClick: async (close) => { + const body_ = { + name: document.getElementById('h-name').value.trim(), + }; + if (!body_.name) { + toast(t('manager2.ui.hosts.nameMissing'), '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..bf0872088bc6 --- /dev/null +++ b/modules/manager2/webapp/js/pages/logs.js @@ -0,0 +1,29 @@ +/* + * 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'; +import { t } from '../i18n.js'; + +export async function logs(container) { + return logPage(container, { + kind: 'log', + title: t('manager2.ui.nav.logs'), + subtitle: t('manager2.ui.logs.subtitle'), + 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..fb266a88b8c8 --- /dev/null +++ b/modules/manager2/webapp/js/pages/monitoring.js @@ -0,0 +1,275 @@ +/* + * 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'; +import { t, numberFormatter } from '../i18n.js'; + +const POLL_MS = 5000; + +const STAGE_LABELS = { + P: 'manager2.ui.monitoring.stage.parsing', + S: 'manager2.ui.monitoring.stage.service', + F: 'manager2.ui.monitoring.stage.finishing', + R: 'manager2.ui.monitoring.stage.ready', + K: 'manager2.ui.monitoring.stage.keepAlive', + '?': 'manager2.ui.monitoring.stage.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' }; + +function pctText(fraction) { + return fraction == null + ? '-' + : numberFormatter({ minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(fraction * 100) + ' %'; +} + +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', {}, t('manager2.ui.nav.monitoring')), + el('p', {}, t('manager2.ui.monitoring.subtitle')))); + 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' }), t('manager2.ui.monitoring.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' }), t('manager2.ui.monitoring.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' }), t('manager2.ui.monitoring.connectors'))), + el('div', { class: 'table-wrap stackable' })); + 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' }), t('manager2.ui.monitoring.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; + } + + 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'); + } catch (err) { + return; + } + renderWorkers(workers); + } + + function renderCpu(cpu) { + clear(cpuBody); + if (!cpu) { + cpuBody.append(el('div', { class: 'empty' }, t('manager2.ui.monitoring.notAvailable'))); + return; + } + cpuBody.append( + metric(t('manager2.ui.monitoring.systemCpu'), pctText(cpu.systemLoad), cpu.systemLoad), + metric(t('manager2.ui.monitoring.processCpu'), pctText(cpu.processLoad), cpu.processLoad), + el('div', { class: 'sys-facts' }, + fact(t('manager2.ui.monitoring.cores'), String(cpu.availableProcessors)), + fact(t('manager2.ui.monitoring.loadAverage'), + cpu.loadAverage != null + ? numberFormatter({ minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(cpu.loadAverage) + : '-'), + fact(t('manager2.ui.monitoring.threads'), t('manager2.ui.monitoring.threadsLive', cpu.threads)), + fact(t('manager2.ui.monitoring.daemonThreads'), String(cpu.daemonThreads)), + fact(t('manager2.ui.monitoring.peakThreads'), String(cpu.peakThreads)))); + } + + function renderMemory(mem) { + clear(memoryBody); + if (!mem) { + memoryBody.append(el('div', { class: 'empty' }, t('manager2.ui.monitoring.notAvailable'))); + return; + } + const heap = mem.heap || {}; + const nonHeap = mem.nonHeap || {}; + memoryBody.append( + mem.physical && mem.physical.total > 0 + ? metric(t('manager2.ui.monitoring.physicalMemory'), + t('manager2.ui.monitoring.usedOfTotal', + formatBytes(mem.physical.total - mem.physical.free), formatBytes(mem.physical.total)), + (mem.physical.total - mem.physical.free) / mem.physical.total) + : metric(t('manager2.ui.monitoring.physicalMemory'), '-', null), + mem.swap && mem.swap.total > 0 + ? metric(t('manager2.ui.monitoring.swap'), + t('manager2.ui.monitoring.usedOfTotal', + formatBytes(mem.swap.total - mem.swap.free), formatBytes(mem.swap.total)), + (mem.swap.total - mem.swap.free) / mem.swap.total) + : metric(t('manager2.ui.monitoring.swap'), t('manager2.ui.monitoring.none'), null), + heap.max > 0 + ? metric(t('manager2.ui.monitoring.jvmHeap'), + t('manager2.ui.monitoring.usedOfTotal', formatBytes(heap.used), formatBytes(heap.max)), + heap.used / heap.max) + : metric(t('manager2.ui.monitoring.jvmHeap'), + t('manager2.ui.monitoring.heapUnbounded', formatBytes(heap.used)), null)); + memoryBody.append(el('div', { class: 'sys-facts' }, + fact(t('manager2.ui.monitoring.heapCommitted'), formatBytes(heap.committed)), + fact(t('manager2.ui.monitoring.nonHeapUsed'), 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', {}, t('manager2.ui.monitoring.pool')), + el('th', {}, t('manager2.ui.col.used')), + el('th', {}, t('manager2.ui.col.committed')), + el('th', {}, t('manager2.ui.col.max')))), + el('tbody', {}, rows)))); + } + } + + function renderConnectors(connectors) { + clear(connectorsWrap); + const rows = connectors.map((c) => el('tr', {}, + el('td', { 'data-label': t('manager2.ui.monitoring.col.connector') }, el('strong', {}, c.name)), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.threads') }, + c.threads.busy + ' / ' + c.threads.current + ' / ' + c.threads.max), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.keepAlive') }, String(c.threads.keepAlive)), + c.requests + ? el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.processingTime') }, + t('manager2.ui.monitoring.processingTime', formatMs(c.requests.processingTime), formatMs(c.requests.maxTime))) + : el('td', { 'data-label': t('manager2.ui.monitoring.col.processingTime') }, '-'), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.requests') }, String(c.requests ? c.requests.count : '-')), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.errors') }, String(c.requests ? c.requests.errors : '-')), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.bytesIn') }, c.requests ? formatBytes(c.requests.bytesReceived) : '-'), + el('td', { class: 'num', 'data-label': t('manager2.ui.monitoring.col.bytesOut') }, c.requests ? formatBytes(c.requests.bytesSent) : '-'))); + connectorsWrap.append(el('table', { class: 'data' }, + el('thead', {}, el('tr', {}, + el('th', {}, t('manager2.ui.monitoring.col.connector')), + el('th', {}, t('manager2.ui.monitoring.col.threadsBusyCurrentMax')), + el('th', {}, t('manager2.ui.monitoring.col.keepAlive')), + el('th', {}, t('manager2.ui.monitoring.col.processingTime')), + el('th', {}, t('manager2.ui.monitoring.col.requests')), + el('th', {}, t('manager2.ui.monitoring.col.errors')), + el('th', {}, t('manager2.ui.monitoring.col.bytesIn')), + el('th', {}, t('manager2.ui.monitoring.col.bytesOut')))), + el('tbody', {}, rows.length > 0 ? rows + : el('tr', {}, el('td', { colspan: '8', class: 'empty' }, t('manager2.ui.monitoring.noConnectors')))))); + } + + 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 = t('manager2.ui.monitoring.workerCount', + active.length, workers.length - active.length); + } + const rows = active.map((w) => el('tr', {}, + el('td', {}, + el('span', { class: 'badge ' + (STAGE_BADGES[w.stage] || 'plain') }, + w.stage + ' ยท ' + (STAGE_LABELS[w.stage] ? t(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', { class: 'wide' }, + (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', {}, t('manager2.ui.monitoring.col.stage')), + el('th', {}, t('manager2.ui.monitoring.col.time')), + el('th', {}, t('manager2.ui.monitoring.col.sent')), + el('th', {}, t('manager2.ui.monitoring.col.received')), + el('th', {}, t('manager2.ui.monitoring.col.remoteAddress')), + el('th', {}, t('manager2.ui.monitoring.col.virtualHost')), + el('th', {}, t('manager2.ui.monitoring.col.request')))), + el('tbody', {}, rows.length > 0 ? rows + : el('tr', {}, el('td', { colspan: '7', class: 'empty' }, t('manager2.ui.monitoring.noWorkers')))))); + } + + 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..2852b5a0a596 --- /dev/null +++ b/modules/manager2/webapp/js/pages/users.js @@ -0,0 +1,532 @@ +/* + * 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, actionMenu } from '../ui.js'; +import { t } from '../i18n.js'; + +// Build a localized message with inline code spans: the {0}, {1}, ... +// placeholders of the message are replaced by the given code values. +function codeNodes(key, codes) { + const message = t(key); + const nodes = []; + const regex = /\{(\d+)\}/g; + let last = 0; + let m; + while ((m = regex.exec(message))) { + if (m.index > last) { + nodes.push(document.createTextNode(message.substring(last, m.index))); + } + nodes.push(el('code', {}, codes[Number(m[1])])); + last = regex.lastIndex; + } + if (last < message.length) { + nodes.push(document.createTextNode(message.substring(last))); + } + return nodes; +} + +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', {}, codeNodes('manager2.ui.users.readonlyBanner', + ['readonly="false"', 'Resource', 'server.xml'])))); + } + 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' }, t('manager2.ui.users.badgeReadonly'))); + } else if (data.writable === false) { + badges.append(el('span', { class: 'badge danger' }, t('manager2.ui.users.badgeNotWritable'))); + } else { + badges.append(el('span', { class: 'badge ok' }, t('manager2.ui.users.badgeWritable'))); + } + const title = el('div', { class: 'card-title-row' }, + el('h2', {}, t('manager2.ui.nav.users')), + badges); + + if ((data.databases || []).length > 1) { + const select = el('select', { + class: 'log-field', + 'aria-label': t('manager2.ui.users.dbSelect'), + 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' }, + codeNodes('manager2.ui.users.jndiResource', [data.name]), + db.id ? t('manager2.ui.users.jndiId', 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: t('manager2.ui.users.inheritedTitle') }, + '+ ' + 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(' ' + t('manager2.ui.users.addUser'))); + + const tbl = table({ + columns: [ + { key: 'username', label: t('manager2.ui.col.user'), render: (u) => el('span', { class: 'user-cell' }, + el('span', {}, u.username), + u.fullName ? el('span', { class: 'muted' }, ' ' + u.fullName) : null) }, + { key: 'roles', label: t('manager2.ui.col.roles'), render: (u) => rolesBadges(u.roles, u.effectiveRoles) }, + { key: 'groups', label: t('manager2.ui.col.groups'), render: (u) => nameBadges(u.groups) }, + { + key: 'actions', label: t('manager2.ui.col.actions'), + render: (u) => actionMenu([ + { label: t('manager2.ui.col.roles'), disabled: !canEdit(), onclick: () => rolesModal('user', u) }, + { label: t('manager2.ui.col.password'), disabled: !canEdit(), onclick: () => passwordModal(u) }, + { label: t('manager2.ui.common.remove'), class: 'btn-danger', disabled: !canEdit(), onclick: () => removeUser(u) }, + ]), + }, + ], + rows: data.users || [], + empty: t('manager2.ui.users.noUsers'), + stackable: true, + }); + + const card = el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, t('manager2.ui.nav.users')), addBtn), + tbl); + 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(' ' + t('manager2.ui.users.addGroup'))); + + const tbl = table({ + columns: [ + { key: 'groupname', label: t('manager2.ui.users.groupColumn'), render: (g) => el('span', {}, g.groupname) }, + { key: 'roles', label: t('manager2.ui.col.roles'), render: (g) => rolesBadges(g.roles, null) }, + { key: 'members', label: t('manager2.ui.col.members'), render: (g) => nameBadges(g.members) }, + { + key: 'actions', label: t('manager2.ui.col.actions'), + render: (g) => actionMenu([ + { label: t('manager2.ui.col.members'), disabled: !canEdit(), onclick: () => membersModal(g) }, + { label: t('manager2.ui.col.roles'), disabled: !canEdit(), onclick: () => rolesModal('group', g) }, + { label: t('manager2.ui.common.remove'), class: 'btn-danger', disabled: !canEdit(), onclick: () => removeGroup(g) }, + ]), + }, + ], + rows: data.groups || [], + empty: t('manager2.ui.users.noGroups'), + stackable: true, + }); + + return el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, t('manager2.ui.users.groupsHeading')), addBtn), + tbl); + } + + 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(' ' + t('manager2.ui.users.addRole'))); + + const tbl = table({ + columns: [ + { key: 'rolename', label: t('manager2.ui.users.roleColumn'), render: (r) => el('span', { class: 'badge plain' }, r.rolename) }, + { key: 'description', label: t('manager2.ui.col.description'), render: (r) => r.description + ? el('span', {}, r.description) + : el('span', { class: 'muted' }, '-') }, + { key: 'users', label: t('manager2.ui.nav.users'), render: (r) => nameBadges((data.users || []) + .filter((u) => (u.roles || []).includes(r.rolename)) + .map((u) => u.username)) }, + { key: 'groups', label: t('manager2.ui.users.groupsHeading'), render: (r) => nameBadges((data.groups || []) + .filter((g) => (g.roles || []).includes(r.rolename)) + .map((g) => g.groupname)) }, + { + key: 'actions', label: t('manager2.ui.col.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); } }, t('manager2.ui.common.remove'))), + }, + ], + rows: data.roles || [], + empty: t('manager2.ui.users.noRoles'), + stackable: true, + }); + + return el('div', { class: 'card' }, + el('div', { class: 'card-title-row' }, el('h3', {}, t('manager2.ui.users.rolesHeading')), addBtn), + el('p', { class: 'muted' }, t('manager2.ui.users.rolesHint')), + tbl); + } + + // ------------------------------- 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(t('manager2.ui.users.rolesField'), 'ud-roles', [], roleNames()); + const rolesInput = roles.querySelector('input'); + + modal({ + title: t('manager2.ui.users.addUser'), + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.col.username')), username), + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.col.password')), password), + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.users.fullNameField')), fullName), + roles), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.users.addUser'), + 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 || t('manager2.ui.users.userAdded')); + 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(t('manager2.ui.users.rolesField'), 'roles-edit', item.roles, roleNames()); + const input = list.querySelector('input'); + + modal({ + title: t('manager2.ui.users.rolesFor', name), + content: el('div', {}, list), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.save'), + class: 'btn-primary', + onClick: async (close) => { + try { + const res = await api('POST', field + '/roles', { roles: parseList(input.value) }); + close(); + toast(res.message || t('manager2.ui.users.rolesSaved')); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + input.focus(); + } + + function passwordModal(user) { + const password = el('input', { type: 'password', autocomplete: 'new-password' }); + modal({ + title: t('manager2.ui.users.passwordFor', user.username), + content: el('div', {}, + el('div', { class: 'field' }, + el('label', {}, t('manager2.ui.users.newPassword')), + password, + el('span', { class: 'hint' }, t('manager2.ui.users.passwordHint')))), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.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 || t('manager2.ui.users.passwordSaved')); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + password.focus(); + } + + async function removeUser(user) { + const ok = await confirm({ + title: t('manager2.ui.users.removeUserTitle'), + message: t('manager2.ui.users.removeUserConfirm', user.username), + confirmLabel: t('manager2.ui.users.removeUserTitle'), + danger: true, + requireText: user.username, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/users/' + encodeURIComponent(user.username)); + toast(res.message || t('manager2.ui.users.userRemoved')); + 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(t('manager2.ui.users.rolesField'), 'ug-roles', [], roleNames()); + const rolesInput = roles.querySelector('input'); + + modal({ + title: t('manager2.ui.users.addGroup'), + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.users.groupNameField')), groupname), + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.users.descriptionField')), description), + roles), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.users.addGroup'), + 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 || t('manager2.ui.users.groupAdded')); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + groupname.focus(); + } + + function membersModal(group) { + const list = listInput(t('manager2.ui.users.membersField'), 'members-edit', group.members, userNames()); + const input = list.querySelector('input'); + + modal({ + title: t('manager2.ui.users.membersOf', group.groupname), + content: el('div', {}, list), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.common.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 || t('manager2.ui.users.membersSaved')); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + input.focus(); + } + + async function removeGroup(group) { + const ok = await confirm({ + title: t('manager2.ui.users.removeGroupTitle'), + message: t('manager2.ui.users.removeGroupConfirm', group.groupname), + confirmLabel: t('manager2.ui.users.removeGroupTitle'), + danger: true, + requireText: group.groupname, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/groups/' + encodeURIComponent(group.groupname)); + toast(res.message || t('manager2.ui.users.groupRemoved')); + 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: t('manager2.ui.users.addRole'), + content: el('div', {}, + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.users.roleNameField')), rolename), + el('div', { class: 'field' }, el('label', {}, t('manager2.ui.users.descriptionField')), description)), + actions: [ + { label: t('manager2.ui.common.cancel') }, + { + label: t('manager2.ui.users.addRole'), + 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 || t('manager2.ui.users.roleAdded')); + load(); + } catch (err) { + toast(err.message, 'error'); + } + }, + }, + ], + }); + rolename.focus(); + } + + async function removeRole(role) { + const ok = await confirm({ + title: t('manager2.ui.users.removeRoleTitle'), + message: t('manager2.ui.users.removeRoleConfirm', role.rolename), + confirmLabel: t('manager2.ui.users.removeRoleTitle'), + danger: true, + requireText: role.rolename, + }); + if (!ok) return; + try { + const res = await api('DELETE', '/api/roles/' + encodeURIComponent(role.rolename)); + toast(res.message || t('manager2.ui.users.roleRemoved')); + 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..d096d71a5e87 --- /dev/null +++ b/modules/manager2/webapp/js/ui.js @@ -0,0 +1,498 @@ +/* + * 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 ============================ + +import { t, locale, numberFormatter } from './i18n.js'; + +/** + * 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 =============================== + +const fBytes1 = () => numberFormatter({ minimumFractionDigits: 1, maximumFractionDigits: 1 }); +const fBytes2 = () => numberFormatter({ minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const fInt = () => numberFormatter({ maximumFractionDigits: 0 }); + +export function formatBytes(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + const abs = Math.abs(n); + if (abs < 1024) return fInt().format(n) + ' B'; + if (abs < 1024 * 1024) return fBytes1().format(n / 1024) + ' KiB'; + if (abs < 1024 * 1024 * 1024) return fBytes1().format(n / 1024 / 1024) + ' MiB'; + return fBytes2().format(n / 1024 / 1024 / 1024) + ' GiB'; +} + +export function formatRate(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + return numberFormatter({ minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n) + '/s'; +} + +export function formatMs(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + if (n < 1000) return fInt().format(n) + ' ms'; + return fBytes2().format(n / 1000) + ' s'; +} + +export function formatSeconds(n) { + if (n === null || n === undefined || isNaN(n)) return '-'; + if (n < 60) return fInt().format(n) + ' s'; + if (n < 3600) return fInt().format(Math.floor(n / 60)) + ' min ' + fInt().format(Math.round(n % 60)) + ' s'; + return fInt().format(Math.floor(n / 3600)) + ' h ' + fInt().format(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 fInt().format(d) + ' d ' + fInt().format(h) + ' h'; + if (h > 0) return fInt().format(h) + ' h ' + fInt().format(m) + ' min'; + if (m > 0) return fInt().format(m) + ' min'; + return fInt().format(Math.max(0, s)) + ' s'; +} + +export function formatTimestamp(ts) { + if (!ts) return '-'; + return new Date(ts).toLocaleString(locale); +} + +export function formatTime(ts) { + if (!ts) return '-'; + return new Date(ts).toLocaleTimeString(locale); +} + +// ============================ 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 ? t('manager2.ui.state.running') : t('manager2.ui.state.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;' }, + typeToConfirmLabel(opts.requireText), + el('input', { type: 'text', autocomplete: 'off' })); + content.append(input); + } + const close = modal({ + title: opts.title, + content, + actions: [ + { label: t('manager2.ui.common.cancel'), onClick: () => finish(false) }, + { + label: opts.confirmLabel || t('manager2.ui.common.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), + }); + }); +} + +/** + * The label of a confirmation that requires typing a value; the typed value + * keeps its code styling and its position is defined by the message itself. + */ +function typeToConfirmLabel(value) { + const label = el('label', {}); + t('manager2.ui.confirm.typeToConfirm', '\u0000').split('\u0000').forEach((part, index) => { + if (index > 0) { + label.append(el('code', {}, value)); + } + label.append(document.createTextNode(part)); + }); + return label; +} + +// ============================ 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': t('manager2.ui.common.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; +} + +// ============================ 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 900 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': t('manager2.ui.common.moreActions'), + '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, + * wide (long text column that may wrap on narrow screens)}], + * rows: [object], sortKey, sortAsc, onSort(key), onRowClick(row), + * 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 = t('manager2.ui.common.noData'), stackable = false } = 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 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) { + node.append(value); + } else { + node.append(document.createTextNode(String(value))); + } + return node; + })))); + + return el('div', { class: 'table-wrap' + (stackable ? ' stackable' : '') }, + 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: '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: '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', + 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': '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: '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'); + 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] || ''); + if (EVENODD_ICONS.has(name)) { + path.setAttribute('fill-rule', 'evenodd'); + } + 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..2487be1fcd5c --- /dev/null +++ b/modules/manager2/webapp/login.html @@ -0,0 +1,54 @@ + + + + + + + + #{manager2.ui.login.title} + + + + + +

+ + + + +
+ +