LoadBalancerDrainingValve.java

/*
 * 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.catalina.valves;

import java.io.IOException;

import jakarta.servlet.ServletException;
import jakarta.servlet.SessionCookieConfig;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.util.SessionConfig;

/**
 * <p>
 * A Valve to detect situations where a load-balanced node receiving a request has been deactivated by the load balancer
 * (JK_LB_ACTIVATION=DIS) and the incoming request has no valid session.
 * </p>
 * <p>
 * In these cases, the user's session cookie should be removed if it exists, any ";jsessionid" parameter should be
 * removed from the request URI, and the client should be redirected to the same URI. This will cause the load-balanced
 * to re-balance the client to another server.
 * </p>
 * <p>
 * All this work is required because when the activation state of a node is DISABLED, the load-balancer will still send
 * requests to the node if they appear to have a session on that node. Since mod_jk doesn't actually know whether the
 * session id is valid, it will send the request blindly to the disabled node, which makes it take much longer to drain
 * the node than strictly necessary.
 * </p>
 * <p>
 * For testing purposes, a special cookie can be configured and used by a client to ignore the normal behavior of this
 * Valve and allow a client to get a new session on a DISABLED node. See {@link #setIgnoreCookieName} and
 * {@link #setIgnoreCookieValue} to configure those values.
 * </p>
 * <p>
 * This Valve should be installed earlier in the Valve pipeline than any authentication valves, as the redirection
 * should take place before an authentication valve would save a request to a protected resource.
 * </p>
 *
 * @see <a href="https://tomcat.apache.org/connectors-doc/generic_howto/loadbalancers.html">Load balancer
 *          documentation</a>
 */
public class LoadBalancerDrainingValve extends ValveBase {

    /**
     * The request attribute key where the load-balancer's activation state can be found.
     */
    public static final String ATTRIBUTE_KEY_JK_LB_ACTIVATION = "JK_LB_ACTIVATION";

    /**
     * The HTTP response code that will be used to redirect the request back to the load-balancer for re-balancing.
     * Defaults to 307 (TEMPORARY_REDIRECT). HTTP status code 305 (USE_PROXY) might be an option, here. too.
     */
    private int _redirectStatusCode = HttpServletResponse.SC_TEMPORARY_REDIRECT;

    /**
     * The name of the cookie which can be set to ignore the "draining" action of this Filter. This will allow a client
     * to contact the server without being re-balanced to another server. The expected cookie value can be set in the
     * {@link #_ignoreCookieValue}. The cookie name and value must match to avoid being re-balanced.
     */
    private String _ignoreCookieName;

    /**
     * The value of the cookie which can be set to ignore the "draining" action of this Filter. This will allow a client
     * to contact the server without being re-balanced to another server. The expected cookie name can be set in the
     * {@link #_ignoreCookieName}. The cookie name and value must match to avoid being re-balanced.
     */
    private String _ignoreCookieValue;

    public LoadBalancerDrainingValve() {
        super(true); // Supports async
    }

    //
    // Configuration parameters
    //

    /**
     * Sets the HTTP response code that will be used to redirect the request back to the load-balancer for re-balancing.
     * Defaults to 307 (TEMPORARY_REDIRECT).
     *
     * @param code The code to use for the redirect
     */
    public void setRedirectStatusCode(int code) {
        _redirectStatusCode = code;
    }

    /**
     * Gets the name of the cookie that can be used to override the re-balancing behavior of this Valve when the current
     * node is in the DISABLED activation state.
     *
     * @return The cookie name used to ignore normal processing rules.
     *
     * @see #setIgnoreCookieValue
     */
    public String getIgnoreCookieName() {
        return _ignoreCookieName;
    }

    /**
     * Sets the name of the cookie that can be used to override the re-balancing behavior of this Valve when the current
     * node is in the DISABLED activation state. There is no default value for this setting: the ability to override the
     * re-balancing behavior of this Valve is <i>disabled</i> by default.
     *
     * @param cookieName The cookie name to use to ignore normal processing rules.
     *
     * @see #getIgnoreCookieValue
     */
    public void setIgnoreCookieName(String cookieName) {
        _ignoreCookieName = cookieName;
    }

    /**
     * Gets the expected value of the cookie that can be used to override the re-balancing behavior of this Valve when
     * the current node is in the DISABLED activation state.
     *
     * @return The cookie value used to ignore normal processing rules.
     *
     * @see #setIgnoreCookieValue
     */
    public String getIgnoreCookieValue() {
        return _ignoreCookieValue;
    }

    /**
     * Sets the expected value of the cookie that can be used to override the re-balancing behavior of this Valve when
     * the current node is in the DISABLED activation state. The "ignore" cookie's value <b>must</b> be exactly equal to
     * this value in order to allow the client to override the re-balancing behavior.
     *
     * @param cookieValue The cookie value to use to ignore normal processing rules.
     *
     * @see #getIgnoreCookieValue
     */
    public void setIgnoreCookieValue(String cookieValue) {
        _ignoreCookieValue = cookieValue;
    }

    @Override
    public void invoke(Request request, Response response) throws IOException, ServletException {
        if ("DIS".equals(request.getAttribute(ATTRIBUTE_KEY_JK_LB_ACTIVATION)) &&
                !request.isRequestedSessionIdValid()) {

            if (containerLog.isDebugEnabled()) {
                containerLog.debug(sm.getString("loadBalancerDrainingValve.draining"));
            }

            boolean ignoreRebalance = false;
            Cookie sessionCookie = null;

            final Cookie[] cookies = request.getCookies();

            final String sessionCookieName = SessionConfig.getSessionCookieName(request.getContext());

            if (null != cookies) {
                for (Cookie cookie : cookies) {
                    final String cookieName = cookie.getName();
                    if (containerLog.isTraceEnabled()) {
                        containerLog.trace("Checking cookie " + cookieName + "=" + cookie.getValue());
                    }

                    if (sessionCookieName.equals(cookieName) &&
                            request.getRequestedSessionId().equals(cookie.getValue())) {
                        sessionCookie = cookie;
                    } else if (null != _ignoreCookieName && _ignoreCookieName.equals(cookieName) &&
                            null != _ignoreCookieValue && _ignoreCookieValue.equals(cookie.getValue())) {
                        // The client presenting a valid ignore-cookie value?
                        ignoreRebalance = true;
                    }
                }
            }

            if (ignoreRebalance) {
                if (containerLog.isDebugEnabled()) {
                    containerLog.debug(sm.getString("loadBalancerDrainingValve.skip", _ignoreCookieName));
                }

                getNext().invoke(request, response);

                return;
            }

            // Kill any session cookie that was found
            // TODO: Consider implications of SSO cookies
            if (null != sessionCookie) {
                sessionCookie.setPath(SessionConfig.getSessionCookiePath(request.getContext()));
                sessionCookie.setMaxAge(0); // Delete
                sessionCookie.setValue(""); // Purge the cookie's value
                // Replicate logic used to set secure attribute for session cookies
                SessionCookieConfig sessionCookieConfig =
                        request.getContext().getServletContext().getSessionCookieConfig();
                sessionCookie.setSecure(request.isSecure() || sessionCookieConfig.isSecure());
                response.addCookie(sessionCookie);
            }

            // Re-write the URI if it contains a ;jsessionid parameter
            String uri = request.getRequestURI();
            String sessionURIParamName = SessionConfig.getSessionUriParamName(request.getContext());
            if (uri.contains(";" + sessionURIParamName + "=")) {
                uri = uri.replaceFirst(";" + sessionURIParamName + "=[^&?]*", "");
            }

            String queryString = request.getQueryString();

            if (null != queryString) {
                uri = uri + "?" + queryString;
            }

            // NOTE: Do not call response.encodeRedirectURL or the bad
            // sessionid will be restored
            response.setHeader("Location", uri);
            response.setStatus(_redirectStatusCode);
        } else {
            getNext().invoke(request, response);
        }
    }
}