Author: markt
Date: Mon Sep  7 12:45:58 2009
New Revision: 812115

URL: http://svn.apache.org/viewvc?rev=812115&view=rev
Log:
Apply AD improvements

Modified:
    tomcat/tc6.0.x/trunk/STATUS.txt
    tomcat/tc6.0.x/trunk/java/org/apache/catalina/realm/JNDIRealm.java
    tomcat/tc6.0.x/trunk/webapps/docs/changelog.xml

Modified: tomcat/tc6.0.x/trunk/STATUS.txt
URL: 
http://svn.apache.org/viewvc/tomcat/tc6.0.x/trunk/STATUS.txt?rev=812115&r1=812114&r2=812115&view=diff
==============================================================================
--- tomcat/tc6.0.x/trunk/STATUS.txt (original)
+++ tomcat/tc6.0.x/trunk/STATUS.txt Mon Sep  7 12:45:58 2009
@@ -144,17 +144,6 @@
   +1: kkolinko, markt, rjung, funkman
   -1: 
 
-* Port Active Directory improvements to JNDIREalm from trunk
-  Patch testing successfully by willing volunteer on the users list
-  http://people.apache.org/~markt/patches/2009-08-06-ADforJNDIRealm.patch
-  +1: markt, kkolinko, funkman
-  -1: 
-  kkolinko: (
-     There are several (two) places with a loop printing containerLog.debug(
-     "Found role: " + it.next()); It would be better to prepare the whole 
string
-     of roles and print it at once.
-  )
-
 * Port TLD processing improvements from trunk
   There have been quite a few changes to TLD processing and they are tightly
   coupled. Therefore, this proposal is a series of patches and the patches

Modified: tomcat/tc6.0.x/trunk/java/org/apache/catalina/realm/JNDIRealm.java
URL: 
http://svn.apache.org/viewvc/tomcat/tc6.0.x/trunk/java/org/apache/catalina/realm/JNDIRealm.java?rev=812115&r1=812114&r2=812115&view=diff
==============================================================================
--- tomcat/tc6.0.x/trunk/java/org/apache/catalina/realm/JNDIRealm.java 
(original)
+++ tomcat/tc6.0.x/trunk/java/org/apache/catalina/realm/JNDIRealm.java Mon Sep  
7 12:45:58 2009
@@ -5,9 +5,9 @@
  * 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.
@@ -24,8 +24,12 @@
 import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Hashtable;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Set;
 
 import javax.naming.Context;
 import javax.naming.CommunicationException;
@@ -37,6 +41,7 @@
 import javax.naming.NameParser;
 import javax.naming.Name;
 import javax.naming.AuthenticationException;
+import javax.naming.PartialResultException;
 import javax.naming.ServiceUnavailableException;
 import javax.naming.directory.Attribute;
 import javax.naming.directory.Attributes;
@@ -134,6 +139,15 @@
  * in the user's element whose name is configured by the
  * <code>userRoleName</code> property.</li>
  *
+ * <li>A default role can be assigned to each user that was successfully
+ * authenticated by setting the <code>commonRole</code> property to the
+ * name of this role. The role doesn't have to exist in the directory.</li>
+ *
+ * <li>If the directory server contains nested roles, you can search for them
+ * by setting <code>roleNested</code> to <code>true</code>.
+ * The default value is <code>false</code>, so role searches will not find
+ * nested roles.</li>
+ *
  * <li>Note that the standard <code>&lt;security-role-ref&gt;</code> element in
  *     the web application deployment descriptor allows applications to refer
  *     to roles programmatically by names other than those used in the
@@ -197,14 +211,14 @@
      */
     protected String contextFactory = "com.sun.jndi.ldap.LdapCtxFactory";
 
-    
+
     /**
      * How aliases should be dereferenced during search operations.
      */
     protected String derefAliases = null;
 
     /**
-     * Constant that holds the name of the environment property for specifying 
+     * Constant that holds the name of the environment property for specifying
      * the manner in which aliases should be dereferenced.
      */
     public final static String DEREF_ALIASES = "java.naming.ldap.derefAliases";
@@ -230,9 +244,20 @@
 
 
     /**
-     * How should we handle referrals?  Microsoft Active Directory can't handle
-     * the default case, so an application authenticating against AD must
-     * set referrals to "follow".
+     * Should we ignore PartialResultExceptions when iterating over 
NamingEnumerations?
+     * Microsoft Active Directory often returns referrals, which lead
+     * to PartialResultExceptions. Unfortunately there's no stable way to 
detect,
+     * if the Exceptions really come from an AD referral.
+     * Set to true to ignore PartialResultExceptions.
+     */
+    protected boolean adCompat = false;
+
+
+    /**
+     * How should we handle referrals?  Microsoft Active Directory often 
returns
+     * referrals. If you need to follow them set referrals to "follow".
+     * Caution: if your DNS is not part of AD, the LDAP client lib might try
+     * to resolve your domain name in DNS to find another LDAP server.
      */
     protected String referrals = null;
 
@@ -294,7 +319,6 @@
      */
     protected MessageFormat[] userPatternFormatArray = null;
 
-
     /**
      * The base element for role searches.
      */
@@ -332,6 +356,12 @@
      * Should we search the entire subtree for matching memberships?
      */
     protected boolean roleSubtree = false;
+    
+    /**
+     * Should we look for nested group in order to determine roles?
+     */
+    protected boolean roleNested = false;
+
 
     /**
      * An alternate URL, to which, we should connect if connectionURL fails.
@@ -345,9 +375,10 @@
     protected int connectionAttempt = 0;
 
     /**
-     * The current user pattern to be used for lookup and binding of a user.
+     *  Add this role to every authenticated user
      */
-    protected int curUserPattern = 0;
+    protected String commonRole = null;
+
 
     // ------------------------------------------------------------- Properties
 
@@ -463,11 +494,11 @@
      */
     public java.lang.String getDerefAliases() {
         return derefAliases;
-    }  
-    
+    }
+
     /**
      * Set the value for derefAliases to be used when searching the directory.
-     * 
+     *
      * @param derefAliases New value of property derefAliases.
      */
     public void setDerefAliases(java.lang.String derefAliases) {
@@ -496,6 +527,23 @@
 
 
     /**
+     * Returns the current settings for handling PartialResultExceptions
+     */
+    public boolean getAdCompat () {
+        return adCompat;
+    }
+
+
+    /**
+     * How do we handle PartialResultExceptions?
+     * True: ignore all PartialResultExceptions.
+     */
+    public void setAdCompat (boolean adCompat) {
+        this.adCompat = adCompat;
+    }
+
+
+    /**
      * Returns the current settings for handling JNDI referrals.
      */
     public String getReferrals () {
@@ -693,6 +741,28 @@
         this.roleSubtree = roleSubtree;
 
     }
+    
+    /**
+     * Return the "The nested group search flag" flag.
+     */
+    public boolean getRoleNested() {
+
+        return (this.roleNested);
+
+    }
+
+
+    /**
+     * Set the "search subtree for roles" flag.
+     *
+     * @param roleNested The nested group search flag
+     */
+    public void setRoleNested(boolean roleNested) {
+
+        this.roleNested = roleNested;
+
+    }
+     
 
 
     /**
@@ -778,6 +848,28 @@
     }
 
 
+    /**
+     * Return the common role
+     */
+    public String getCommonRole() {
+
+        return commonRole;
+
+    }
+
+
+    /**
+     * Set the common role
+     *
+     * @param commonRole The common role
+     */
+    public void setCommonRole(String commonRole) {
+
+        this.commonRole = commonRole;
+
+    }
+
+
     // ---------------------------------------------------------- Realm Methods
 
 
@@ -877,6 +969,8 @@
                 close(context);
 
             // Return "not authenticated" for this request
+            if (containerLog.isDebugEnabled())
+                containerLog.debug("Returning null principal.");
             return (null);
 
         }
@@ -907,21 +1001,30 @@
         throws NamingException {
 
         if (username == null || username.equals("")
-            || credentials == null || credentials.equals(""))
+            || credentials == null || credentials.equals("")) {
+            if (containerLog.isDebugEnabled())
+                containerLog.debug("username null or empty: returning null 
principal.");
             return (null);
+        }
 
         if (userPatternArray != null) {
-            for (curUserPattern = 0;
+            for (int curUserPattern = 0;
                  curUserPattern < userPatternFormatArray.length;
                  curUserPattern++) {
                 // Retrieve user information
-                User user = getUser(context, username);
+                User user = getUser(context, username, credentials, 
curUserPattern);
                 if (user != null) {
                     try {
                         // Check the user's credentials
                         if (checkCredentials(context, user, credentials)) {
                             // Search for additional roles
                             List<String> roles = getRoles(context, user);
+                            if (containerLog.isDebugEnabled()) {
+                                Iterator<String> it = roles.iterator();
+                                while (it.hasNext()) {
+                                    containerLog.debug("Found role: " + 
it.next());
+                                }
+                            }
                             return (new GenericPrincipal(this,
                                                          username,
                                                          credentials,
@@ -940,7 +1043,7 @@
             return null;
         } else {
             // Retrieve user information
-            User user = getUser(context, username);
+            User user = getUser(context, username, credentials);
             if (user == null)
                 return (null);
 
@@ -950,6 +1053,12 @@
 
             // Search for additional roles
             List<String> roles = getRoles(context, user);
+            if (containerLog.isDebugEnabled()) {
+                Iterator<String> it = roles.iterator();
+                while (it.hasNext()) {
+                    containerLog.debug("Found role: " + it.next());
+                }
+            }
 
             // Create and return a suitable Principal for this user
             return (new GenericPrincipal(this, username, credentials, roles));
@@ -962,6 +1071,45 @@
      * with the specified username, if found in the directory;
      * otherwise return <code>null</code>.
      *
+     * @param context The directory context
+     * @param username Username to be looked up
+     *
+     * @exception NamingException if a directory server error occurs
+     *
+     * @see #getUser(DirContext, String, String, int)
+     */
+    protected User getUser(DirContext context, String username)
+        throws NamingException {
+
+        return getUser(context, username, null, -1);
+    }
+
+
+    /**
+     * Return a User object containing information about the user
+     * with the specified username, if found in the directory;
+     * otherwise return <code>null</code>.
+     *
+     * @param context The directory context
+     * @param username Username to be looked up
+     * @param credentials User credentials (optional)
+     *
+     * @exception NamingException if a directory server error occurs
+     *
+     * @see #getUser(DirContext, String, int)
+     */
+    protected User getUser(DirContext context, String username, String 
credentials)
+        throws NamingException {
+
+        return getUser(context, username, credentials, -1);
+    }
+
+
+    /**
+     * Return a User object containing information about the user
+     * with the specified username, if found in the directory;
+     * otherwise return <code>null</code>.
+     *
      * If the <code>userPassword</code> configuration attribute is
      * specified, the value of that attribute is retrieved from the
      * user's directory entry. If the <code>userRoleName</code>
@@ -970,10 +1118,13 @@
      *
      * @param context The directory context
      * @param username Username to be looked up
+     * @param credentials User credentials (optional)
+     * @param curUserPattern Index into userPatternFormatArray
      *
      * @exception NamingException if a directory server error occurs
      */
-    protected User getUser(DirContext context, String username)
+    protected User getUser(DirContext context, String username,
+                           String credentials, int curUserPattern)
         throws NamingException {
 
         User user = null;
@@ -988,8 +1139,8 @@
         list.toArray(attrIds);
 
         // Use pattern or search for user entry
-        if (userPatternFormatArray != null) {
-            user = getUserByPattern(context, username, attrIds);
+        if (userPatternFormatArray != null && curUserPattern >= 0) {
+            user = getUserByPattern(context, username, credentials, attrIds, 
curUserPattern);
         } else {
             user = getUserBySearch(context, username, attrIds);
         }
@@ -999,29 +1150,24 @@
 
 
     /**
-     * Use the <code>UserPattern</code> configuration attribute to
-     * locate the directory entry for the user with the specified
-     * username and return a User object; otherwise return
-     * <code>null</code>.
+     * Use the distinguished name to locate the directory
+     * entry for the user with the specified username and
+     * return a User object; otherwise return <code>null</code>.
      *
      * @param context The directory context
      * @param username The username
      * @param attrIds String[]containing names of attributes to
+     * @param dn Distinguished name of the user
      * retrieve.
      *
      * @exception NamingException if a directory server error occurs
      */
     protected User getUserByPattern(DirContext context,
-                                              String username,
-                                              String[] attrIds)
+                                    String username,
+                                    String[] attrIds,
+                                    String dn)
         throws NamingException {
 
-        if (username == null || userPatternFormatArray[curUserPattern] == null)
-            return (null);
-
-        // Form the dn from the user pattern
-        String dn = userPatternFormatArray[curUserPattern].format(new String[] 
{ username });
-
         // Get required attributes from user entry
         Attributes attrs = null;
         try {
@@ -1047,6 +1193,71 @@
 
 
     /**
+     * Use the <code>UserPattern</code> configuration attribute to
+     * locate the directory entry for the user with the specified
+     * username and return a User object; otherwise return
+     * <code>null</code>.
+     *
+     * @param context The directory context
+     * @param username The username
+     * @param credentials User credentials (optional)
+     * @param attrIds String[]containing names of attributes to
+     * @param curUserPattern Index into userPatternFormatArray
+     *
+     * @exception NamingException if a directory server error occurs
+     * @see #getUserByPattern(DirContext, String, String[], String)
+     */
+    protected User getUserByPattern(DirContext context,
+                                    String username,
+                                    String credentials,
+                                    String[] attrIds,
+                                    int curUserPattern)
+        throws NamingException {
+
+        User user = null;
+
+        if (username == null || userPatternFormatArray[curUserPattern] == null)
+            return (null);
+
+        // Form the dn from the user pattern
+        String dn = userPatternFormatArray[curUserPattern].format(new String[] 
{ username });
+
+        try {
+            user = getUserByPattern(context, username, attrIds, dn);
+        } catch (NameNotFoundException e) {
+            return (null);
+        } catch (NamingException e) {
+            // If the getUserByPattern() call fails, try it again with the
+            // credentials of the user that we're searching for
+            try {
+                // Set up security environment to bind as the user
+                context.addToEnvironment(Context.SECURITY_PRINCIPAL, dn);
+                context.addToEnvironment(Context.SECURITY_CREDENTIALS, 
credentials);
+
+                user = getUserByPattern(context, username, attrIds, dn);
+            } finally {
+                // Restore the original security environment
+                if (connectionName != null) {
+                    context.addToEnvironment(Context.SECURITY_PRINCIPAL,
+                                             connectionName);
+                } else {
+                    context.removeFromEnvironment(Context.SECURITY_PRINCIPAL);
+                }
+
+                if (connectionPassword != null) {
+                    context.addToEnvironment(Context.SECURITY_CREDENTIALS,
+                                             connectionPassword);
+                }
+                else {
+                    
context.removeFromEnvironment(Context.SECURITY_CREDENTIALS);
+                }
+            }
+        }
+        return user;
+    }
+
+
+    /**
      * Search the directory to return a User object containing
      * information about the user with the specified username, if
      * found in the directory; otherwise return <code>null</code>.
@@ -1058,8 +1269,8 @@
      * @exception NamingException if a directory server error occurs
      */
     protected User getUserBySearch(DirContext context,
-                                           String username,
-                                           String[] attrIds)
+                                   String username,
+                                   String[] attrIds)
         throws NamingException {
 
         if (username == null || userSearchFormat == null)
@@ -1083,36 +1294,38 @@
             attrIds = new String[0];
         constraints.setReturningAttributes(attrIds);
 
-        NamingEnumeration results =
+        NamingEnumeration<SearchResult> results =
             context.search(userBase, filter, constraints);
 
 
         // Fail if no entries found
-        if (results == null || !results.hasMore()) {
-            return (null);
+        try {
+            if (results == null || !results.hasMore()) {
+                return (null);
+            }
+        } catch (PartialResultException ex) {
+            if (!adCompat)
+                throw ex;
+            else
+                return (null);
         }
 
         // Get result for the first entry found
-        SearchResult result = (SearchResult)results.next();
+        SearchResult result = results.next();
 
         // Check no further entries were found
-        if (results.hasMore()) {
-            if(containerLog.isInfoEnabled())
-                containerLog.info("username " + username + " has multiple 
entries");
-            return (null);
+        try {
+            if (results.hasMore()) {
+                if(containerLog.isInfoEnabled())
+                    containerLog.info("username " + username + " has multiple 
entries");
+                return (null);
+            }
+        } catch (PartialResultException ex) {
+            if (!adCompat)
+                throw ex;
         }
 
-        // Get the entry's distinguished name
-        NameParser parser = context.getNameParser("");
-        Name contextName = parser.parse(context.getNameInNamespace());
-        Name baseName = parser.parse(userBase);
-
-        // Bugzilla 32269
-        Name entryName = parser.parse(new 
CompositeName(result.getName()).get(0));
-
-        Name name = contextName.addAll(baseName);
-        name = name.addAll(entryName);
-        String dn = name.toString();
+        String dn = getDistinguishedName(context, userBase, result);
 
         if (containerLog.isTraceEnabled())
             containerLog.trace("  entry found for " + username + " with dn " + 
dn);
@@ -1333,7 +1546,6 @@
         return (validated);
      }
 
-
     /**
      * Return a List of roles associated with the given User.  Any
      * roles present in the user's directory entry are supplemented by
@@ -1365,11 +1577,19 @@
         if (list == null) {
             list = new ArrayList<String>();
         }
+        if (commonRole != null)
+            list.add(commonRole);
+
+        if (containerLog.isTraceEnabled()) {
+            containerLog.trace("  Found " + list.size() + " user internal 
roles");
+            for (int i=0; i<list.size(); i++)
+                containerLog.trace(  "  Found user internal role " + 
list.get(i));
+        }
 
         // Are we configured to do role searches?
         if ((roleFormat == null) || (roleName == null))
             return (list);
-
+        
         // Set up parameters for an appropriate search
         String filter = roleFormat.format(new String[] { 
doRFC2254Encoding(dn), username });
         SearchControls controls = new SearchControls();
@@ -1380,30 +1600,86 @@
         controls.setReturningAttributes(new String[] {roleName});
 
         // Perform the configured search and process the results
-        NamingEnumeration results =
+        NamingEnumeration<SearchResult> results =
             context.search(roleBase, filter, controls);
         if (results == null)
             return (list);  // Should never happen, but just in case ...
-        while (results.hasMore()) {
-            SearchResult result = (SearchResult) results.next();
-            Attributes attrs = result.getAttributes();
-            if (attrs == null)
-                continue;
-            list = addAttributeValues(roleName, attrs, list);
-        }
 
+        HashMap<String, String> groupMap = new HashMap<String, String>();
+        try {
+            while (results.hasMore()) {
+                SearchResult result = results.next();
+                Attributes attrs = result.getAttributes();
+                if (attrs == null)
+                    continue;
+                String dname = getDistinguishedName(context, roleBase, result);
+                String name = getAttributeValue(roleName, attrs);
+                if (name != null && dname != null) {
+                    groupMap.put(dname, name);
+                }
+            }
+        } catch (PartialResultException ex) {
+            if (!adCompat)
+                throw ex;
+        }
 
+        Set<String> keys = groupMap.keySet();
         if (containerLog.isTraceEnabled()) {
-            if (list != null) {
-                containerLog.trace("  Returning " + list.size() + " roles");
-                for (int i=0; i<list.size(); i++)
-                    containerLog.trace(  "  Found role " + list.get(i));
-            } else {
-                containerLog.trace("  getRoles about to return null ");
+            containerLog.trace("  Found " + keys.size() + " direct roles");
+            for (String key: keys) {
+                containerLog.trace(  "  Found direct role " + key + " -> " + 
groupMap.get(key));
             }
         }
 
-        return (list);
+        // if nested group search is enabled, perform searches for nested 
groups until no new group is found
+        if (getRoleNested()) {
+
+            // The following efficient algorithm is known as memberOf 
Algorithm, as described in "Practices in 
+            // Directory Groups". It avoids group slurping and handles cyclic 
group memberships as well.
+            // See http://middleware.internet2.edu/dir/ for details
+
+            Set<String> newGroupDNs = new HashSet<String>(groupMap.keySet());
+            while (!newGroupDNs.isEmpty()) {
+                Set<String> newThisRound = new HashSet<String>(); // Stores 
the groups we find in this iteration
+
+                for (String groupDN : newGroupDNs) {
+                    filter = roleFormat.format(new String[] { groupDN });
+
+                    if (containerLog.isTraceEnabled()) {
+                        containerLog.trace("Perform a nested group search with 
base "+ roleBase + " and filter " + filter);
+                    }
+
+                    results = context.search(roleBase, filter, controls);
+
+                    try {
+                        while (results.hasMore()) {
+                            SearchResult result = results.next();
+                            Attributes attrs = result.getAttributes();
+                            if (attrs == null)
+                                continue;
+                            String dname = getDistinguishedName(context, 
roleBase, result);
+                            String name = getAttributeValue(roleName, attrs);
+                            if (name != null && dname != null && 
!groupMap.keySet().contains(dname)) {
+                                groupMap.put(dname, name);
+                                newThisRound.add(dname);
+
+                                if (containerLog.isTraceEnabled()) {
+                                    containerLog.trace("  Found nested role " 
+ dname + " -> " + name);
+                                }
+
+                            }
+                         }
+                    } catch (PartialResultException ex) {
+                        if (!adCompat)
+                            throw ex;
+                    }
+                }
+
+                newGroupDNs = newThisRound;
+            }
+        }
+
+        return new ArrayList<String>(groupMap.values());
     }
 
 
@@ -1464,10 +1740,15 @@
         Attribute attr = attrs.get(attrId);
         if (attr == null)
             return (values);
-        NamingEnumeration e = attr.getAll();
-        while(e.hasMore()) {
-            String value = (String)e.next();
-            values.add(value);
+        NamingEnumeration<?> e = attr.getAll();
+        try {
+            while(e.hasMore()) {
+                String value = (String)e.next();
+                values.add(value);
+            }
+        } catch (PartialResultException ex) {
+            if (!adCompat)
+                throw ex;
         }
         return values;
     }
@@ -1599,9 +1880,9 @@
     protected synchronized Principal getPrincipal(DirContext context,
                                                   String username)
         throws NamingException {
-        
+
         User user = getUser(context, username);
-        
+
         return new GenericPrincipal(this, user.username, user.password ,
                 getRoles(context, user));
     }
@@ -1650,7 +1931,7 @@
      *
      * @return java.util.Hashtable the configuration for the directory context.
      */
-    protected Hashtable getDirectoryContextEnvironment() {
+    protected Hashtable<String,String> getDirectoryContextEnvironment() {
 
         Hashtable<String,String> env = new Hashtable<String,String>();
 
@@ -1689,7 +1970,7 @@
      */
     protected void release(DirContext context) {
 
-        ; // NO-OP since we are not pooling anything
+        // NO-OP since we are not pooling anything
 
     }
 
@@ -1773,7 +2054,7 @@
                 startingPoint = endParenLoc+1;
                 startParenLoc = userPatternString.indexOf('(', startingPoint);
             }
-            return (String[])pathList.toArray(new String[] {});
+            return pathList.toArray(new String[] {});
         }
         return null;
 

Modified: tomcat/tc6.0.x/trunk/webapps/docs/changelog.xml
URL: 
http://svn.apache.org/viewvc/tomcat/tc6.0.x/trunk/webapps/docs/changelog.xml?rev=812115&r1=812114&r2=812115&view=diff
==============================================================================
--- tomcat/tc6.0.x/trunk/webapps/docs/changelog.xml (original)
+++ tomcat/tc6.0.x/trunk/webapps/docs/changelog.xml Mon Sep  7 12:45:58 2009
@@ -155,6 +155,11 @@
         Correct JDBC driver de-registration on web application stop and fix NPE
         that is exposed by the fix. (markt)
       </fix>
+      <update>
+        Various JNDI realm improvements for Active Directory. These include the
+        ability to specify a default role, optional handling for nested roles
+        and an option to ignore PartialResultExceptions (markt). 
+      </update>
     </changelog>
   </subsection>
   <subsection name="Coyote">



---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org
For additional commands, e-mail: dev-h...@tomcat.apache.org

Reply via email to